Implement database persistence for dashboards, legal, and compliance modules

- Add PostgreSQL persistence for dashboards module (was returning empty vec![])
  - Tables: dashboards, dashboard_widgets, dashboard_data_sources, dashboard_filters,
    dashboard_widget_data_sources, conversational_queries
  - Full CRUD operations with spawn_blocking pattern

- Add PostgreSQL persistence for legal module (was using in-memory HashMap)
  - Tables: legal_documents, legal_document_versions, cookie_consents, consent_history,
    legal_acceptances, data_deletion_requests, data_export_requests
  - GDPR-compliant consent tracking and document management

- Add PostgreSQL persistence for compliance module (was returning empty results)
  - Tables: compliance_checks, compliance_issues, compliance_audit_log, compliance_evidence,
    compliance_risk_assessments, compliance_risks, compliance_training_records,
    compliance_access_reviews
  - Support for GDPR, SOC2, ISO27001, HIPAA, PCI-DSS frameworks

- Add migration files for all new tables
- Update schema.rs with new table definitions and joinables
- Register new routes in main.rs
- Add recursion_limit = 512 for macro expansion
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-13 00:07:22 -03:00
parent 67c9b0e0cc
commit a886478548
65 changed files with 25109 additions and 5165 deletions

View file

@ -115,7 +115,8 @@ base64 = "0.22"
bytes = "1.8"
chrono = { version = "0.4", features = ["serde"] }
color-eyre = "0.6.5"
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2", "numeric"] }
bigdecimal = { version = "0.4", features = ["serde"] }
diesel_migrations = "2.1.0"
dirs = "5.0"
dotenvy = "0.15"

View file

@ -0,0 +1,7 @@
DROP TABLE IF EXISTS crm_notes;
DROP TABLE IF EXISTS crm_activities;
DROP TABLE IF EXISTS crm_opportunities;
DROP TABLE IF EXISTS crm_leads;
DROP TABLE IF EXISTS crm_pipeline_stages;
DROP TABLE IF EXISTS crm_accounts;
DROP TABLE IF EXISTS crm_contacts;

View file

@ -0,0 +1,231 @@
CREATE TABLE IF NOT EXISTS crm_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
first_name VARCHAR(255),
last_name VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(50),
mobile VARCHAR(50),
company VARCHAR(255),
job_title VARCHAR(255),
source VARCHAR(100),
status VARCHAR(50) NOT NULL DEFAULT 'active',
tags TEXT[] DEFAULT '{}',
custom_fields JSONB DEFAULT '{}',
address_line1 VARCHAR(500),
address_line2 VARCHAR(500),
city VARCHAR(255),
state VARCHAR(255),
postal_code VARCHAR(50),
country VARCHAR(100),
notes TEXT,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_crm_contacts_org ON crm_contacts(org_id);
CREATE INDEX idx_crm_contacts_bot ON crm_contacts(bot_id);
CREATE INDEX idx_crm_contacts_email ON crm_contacts(email);
CREATE INDEX idx_crm_contacts_owner ON crm_contacts(owner_id);
CREATE INDEX idx_crm_contacts_status ON crm_contacts(status);
CREATE TABLE IF NOT EXISTS crm_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
website VARCHAR(500),
industry VARCHAR(100),
employees_count INTEGER,
annual_revenue DECIMAL(15,2),
phone VARCHAR(50),
email VARCHAR(255),
address_line1 VARCHAR(500),
address_line2 VARCHAR(500),
city VARCHAR(255),
state VARCHAR(255),
postal_code VARCHAR(50),
country VARCHAR(100),
description TEXT,
tags TEXT[] DEFAULT '{}',
custom_fields JSONB DEFAULT '{}',
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_crm_accounts_org ON crm_accounts(org_id);
CREATE INDEX idx_crm_accounts_bot ON crm_accounts(bot_id);
CREATE INDEX idx_crm_accounts_name ON crm_accounts(name);
CREATE INDEX idx_crm_accounts_owner ON crm_accounts(owner_id);
CREATE TABLE IF NOT EXISTS crm_pipeline_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
stage_order INTEGER NOT NULL,
probability INTEGER NOT NULL DEFAULT 0,
is_won BOOLEAN NOT NULL DEFAULT FALSE,
is_lost BOOLEAN NOT NULL DEFAULT FALSE,
color VARCHAR(7) DEFAULT '#3b82f6',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT crm_pipeline_stages_unique UNIQUE (org_id, bot_id, name)
);
CREATE INDEX idx_crm_pipeline_stages_org ON crm_pipeline_stages(org_id);
CREATE INDEX idx_crm_pipeline_stages_bot ON crm_pipeline_stages(bot_id);
CREATE INDEX idx_crm_pipeline_stages_order ON crm_pipeline_stages(stage_order);
CREATE TABLE IF NOT EXISTS crm_leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
contact_id UUID REFERENCES crm_contacts(id) ON DELETE SET NULL,
account_id UUID REFERENCES crm_accounts(id) ON DELETE SET NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
value DECIMAL(15,2),
currency VARCHAR(3) DEFAULT 'USD',
stage_id UUID REFERENCES crm_pipeline_stages(id) ON DELETE SET NULL,
stage VARCHAR(100) NOT NULL DEFAULT 'new',
probability INTEGER NOT NULL DEFAULT 0,
source VARCHAR(100),
expected_close_date DATE,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
lost_reason VARCHAR(500),
tags TEXT[] DEFAULT '{}',
custom_fields JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
closed_at TIMESTAMPTZ
);
CREATE INDEX idx_crm_leads_org ON crm_leads(org_id);
CREATE INDEX idx_crm_leads_bot ON crm_leads(bot_id);
CREATE INDEX idx_crm_leads_contact ON crm_leads(contact_id);
CREATE INDEX idx_crm_leads_account ON crm_leads(account_id);
CREATE INDEX idx_crm_leads_stage ON crm_leads(stage);
CREATE INDEX idx_crm_leads_owner ON crm_leads(owner_id);
CREATE INDEX idx_crm_leads_expected_close ON crm_leads(expected_close_date);
CREATE TABLE IF NOT EXISTS crm_opportunities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(id) ON DELETE SET NULL,
account_id UUID REFERENCES crm_accounts(id) ON DELETE SET NULL,
contact_id UUID REFERENCES crm_contacts(id) ON DELETE SET NULL,
name VARCHAR(500) NOT NULL,
description TEXT,
value DECIMAL(15,2),
currency VARCHAR(3) DEFAULT 'USD',
stage_id UUID REFERENCES crm_pipeline_stages(id) ON DELETE SET NULL,
stage VARCHAR(100) NOT NULL DEFAULT 'qualification',
probability INTEGER NOT NULL DEFAULT 0,
source VARCHAR(100),
expected_close_date DATE,
actual_close_date DATE,
won BOOLEAN,
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
tags TEXT[] DEFAULT '{}',
custom_fields JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_crm_opportunities_org ON crm_opportunities(org_id);
CREATE INDEX idx_crm_opportunities_bot ON crm_opportunities(bot_id);
CREATE INDEX idx_crm_opportunities_lead ON crm_opportunities(lead_id);
CREATE INDEX idx_crm_opportunities_account ON crm_opportunities(account_id);
CREATE INDEX idx_crm_opportunities_stage ON crm_opportunities(stage);
CREATE INDEX idx_crm_opportunities_owner ON crm_opportunities(owner_id);
CREATE INDEX idx_crm_opportunities_won ON crm_opportunities(won);
CREATE TABLE IF NOT EXISTS crm_activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
contact_id UUID REFERENCES crm_contacts(id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(id) ON DELETE CASCADE,
opportunity_id UUID REFERENCES crm_opportunities(id) ON DELETE CASCADE,
account_id UUID REFERENCES crm_accounts(id) ON DELETE CASCADE,
activity_type VARCHAR(50) NOT NULL,
subject VARCHAR(500),
description TEXT,
due_date TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
outcome VARCHAR(255),
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_crm_activities_org ON crm_activities(org_id);
CREATE INDEX idx_crm_activities_contact ON crm_activities(contact_id);
CREATE INDEX idx_crm_activities_lead ON crm_activities(lead_id);
CREATE INDEX idx_crm_activities_opportunity ON crm_activities(opportunity_id);
CREATE INDEX idx_crm_activities_type ON crm_activities(activity_type);
CREATE INDEX idx_crm_activities_due ON crm_activities(due_date);
CREATE INDEX idx_crm_activities_owner ON crm_activities(owner_id);
CREATE TABLE IF NOT EXISTS crm_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(org_id) ON DELETE CASCADE,
contact_id UUID REFERENCES crm_contacts(id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(id) ON DELETE CASCADE,
opportunity_id UUID REFERENCES crm_opportunities(id) ON DELETE CASCADE,
account_id UUID REFERENCES crm_accounts(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_crm_notes_contact ON crm_notes(contact_id);
CREATE INDEX idx_crm_notes_lead ON crm_notes(lead_id);
CREATE INDEX idx_crm_notes_opportunity ON crm_notes(opportunity_id);
CREATE INDEX idx_crm_notes_account ON crm_notes(account_id);
INSERT INTO crm_pipeline_stages (org_id, bot_id, name, stage_order, probability, is_won, is_lost, color)
SELECT org_id, b.id, 'New', 1, 10, FALSE, FALSE, '#94a3b8'
FROM organizations o
CROSS JOIN bots b
LIMIT 1
ON CONFLICT DO NOTHING;
INSERT INTO crm_pipeline_stages (org_id, bot_id, name, stage_order, probability, is_won, is_lost, color)
SELECT org_id, b.id, 'Qualified', 2, 25, FALSE, FALSE, '#3b82f6'
FROM organizations o
CROSS JOIN bots b
LIMIT 1
ON CONFLICT DO NOTHING;
INSERT INTO crm_pipeline_stages (org_id, bot_id, name, stage_order, probability, is_won, is_lost, color)
SELECT org_id, b.id, 'Proposal', 3, 50, FALSE, FALSE, '#8b5cf6'
FROM organizations o
CROSS JOIN bots b
LIMIT 1
ON CONFLICT DO NOTHING;
INSERT INTO crm_pipeline_stages (org_id, bot_id, name, stage_order, probability, is_won, is_lost, color)
SELECT org_id, b.id, 'Negotiation', 4, 75, FALSE, FALSE, '#f59e0b'
FROM organizations o
CROSS JOIN bots b
LIMIT 1
ON CONFLICT DO NOTHING;
INSERT INTO crm_pipeline_stages (org_id, bot_id, name, stage_order, probability, is_won, is_lost, color)
SELECT org_id, b.id, 'Won', 5, 100, TRUE, FALSE, '#22c55e'
FROM organizations o
CROSS JOIN bots b
LIMIT 1
ON CONFLICT DO NOTHING;
INSERT INTO crm_pipeline_stages (org_id, bot_id, name, stage_order, probability, is_won, is_lost, color)
SELECT org_id, b.id, 'Lost', 6, 0, FALSE, TRUE, '#ef4444'
FROM organizations o
CROSS JOIN bots b
LIMIT 1
ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,30 @@
DROP INDEX IF EXISTS idx_ticket_tags_org_name;
DROP INDEX IF EXISTS idx_ticket_tags_org_bot;
DROP INDEX IF EXISTS idx_ticket_categories_parent;
DROP INDEX IF EXISTS idx_ticket_categories_org_bot;
DROP INDEX IF EXISTS idx_ticket_canned_shortcut;
DROP INDEX IF EXISTS idx_ticket_canned_org_bot;
DROP INDEX IF EXISTS idx_ticket_sla_priority;
DROP INDEX IF EXISTS idx_ticket_sla_org_bot;
DROP INDEX IF EXISTS idx_ticket_comments_created;
DROP INDEX IF EXISTS idx_ticket_comments_ticket;
DROP INDEX IF EXISTS idx_support_tickets_org_number;
DROP INDEX IF EXISTS idx_support_tickets_number;
DROP INDEX IF EXISTS idx_support_tickets_created;
DROP INDEX IF EXISTS idx_support_tickets_requester;
DROP INDEX IF EXISTS idx_support_tickets_assignee;
DROP INDEX IF EXISTS idx_support_tickets_priority;
DROP INDEX IF EXISTS idx_support_tickets_status;
DROP INDEX IF EXISTS idx_support_tickets_org_bot;
DROP TABLE IF EXISTS ticket_tags;
DROP TABLE IF EXISTS ticket_categories;
DROP TABLE IF EXISTS ticket_canned_responses;
DROP TABLE IF EXISTS ticket_sla_policies;
DROP TABLE IF EXISTS ticket_comments;
DROP TABLE IF EXISTS support_tickets;

View file

@ -0,0 +1,113 @@
CREATE TABLE support_tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
ticket_number VARCHAR(50) NOT NULL,
subject VARCHAR(500) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'open',
priority VARCHAR(50) NOT NULL DEFAULT 'medium',
category VARCHAR(100),
source VARCHAR(50) NOT NULL DEFAULT 'web',
requester_id UUID,
requester_email VARCHAR(255),
requester_name VARCHAR(255),
assignee_id UUID,
team_id UUID,
due_date TIMESTAMPTZ,
first_response_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
satisfaction_rating INTEGER,
tags TEXT[] NOT NULL DEFAULT '{}',
custom_fields JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE ticket_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
author_id UUID,
author_name VARCHAR(255),
author_email VARCHAR(255),
content TEXT NOT NULL,
is_internal BOOLEAN NOT NULL DEFAULT FALSE,
attachments JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE ticket_sla_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
priority VARCHAR(50) NOT NULL,
first_response_hours INTEGER NOT NULL,
resolution_hours INTEGER NOT NULL,
business_hours_only BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE ticket_canned_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
category VARCHAR(100),
shortcut VARCHAR(50),
created_by UUID,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE ticket_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
parent_id UUID REFERENCES ticket_categories(id) ON DELETE SET NULL,
color VARCHAR(20),
icon VARCHAR(50),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE ticket_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
color VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_support_tickets_org_bot ON support_tickets(org_id, bot_id);
CREATE INDEX idx_support_tickets_status ON support_tickets(status);
CREATE INDEX idx_support_tickets_priority ON support_tickets(priority);
CREATE INDEX idx_support_tickets_assignee ON support_tickets(assignee_id);
CREATE INDEX idx_support_tickets_requester ON support_tickets(requester_id);
CREATE INDEX idx_support_tickets_created ON support_tickets(created_at DESC);
CREATE INDEX idx_support_tickets_number ON support_tickets(ticket_number);
CREATE UNIQUE INDEX idx_support_tickets_org_number ON support_tickets(org_id, ticket_number);
CREATE INDEX idx_ticket_comments_ticket ON ticket_comments(ticket_id);
CREATE INDEX idx_ticket_comments_created ON ticket_comments(created_at);
CREATE INDEX idx_ticket_sla_org_bot ON ticket_sla_policies(org_id, bot_id);
CREATE INDEX idx_ticket_sla_priority ON ticket_sla_policies(priority);
CREATE INDEX idx_ticket_canned_org_bot ON ticket_canned_responses(org_id, bot_id);
CREATE INDEX idx_ticket_canned_shortcut ON ticket_canned_responses(shortcut);
CREATE INDEX idx_ticket_categories_org_bot ON ticket_categories(org_id, bot_id);
CREATE INDEX idx_ticket_categories_parent ON ticket_categories(parent_id);
CREATE INDEX idx_ticket_tags_org_bot ON ticket_tags(org_id, bot_id);
CREATE UNIQUE INDEX idx_ticket_tags_org_name ON ticket_tags(org_id, bot_id, name);

View file

@ -0,0 +1,36 @@
DROP INDEX IF EXISTS idx_billing_tax_rates_org_bot;
DROP INDEX IF EXISTS idx_billing_recurring_next;
DROP INDEX IF EXISTS idx_billing_recurring_status;
DROP INDEX IF EXISTS idx_billing_recurring_org_bot;
DROP INDEX IF EXISTS idx_billing_quote_items_quote;
DROP INDEX IF EXISTS idx_billing_quotes_number;
DROP INDEX IF EXISTS idx_billing_quotes_valid_until;
DROP INDEX IF EXISTS idx_billing_quotes_customer;
DROP INDEX IF EXISTS idx_billing_quotes_status;
DROP INDEX IF EXISTS idx_billing_quotes_org_bot;
DROP INDEX IF EXISTS idx_billing_payments_number;
DROP INDEX IF EXISTS idx_billing_payments_paid_at;
DROP INDEX IF EXISTS idx_billing_payments_status;
DROP INDEX IF EXISTS idx_billing_payments_invoice;
DROP INDEX IF EXISTS idx_billing_payments_org_bot;
DROP INDEX IF EXISTS idx_billing_invoice_items_invoice;
DROP INDEX IF EXISTS idx_billing_invoices_number;
DROP INDEX IF EXISTS idx_billing_invoices_created;
DROP INDEX IF EXISTS idx_billing_invoices_due_date;
DROP INDEX IF EXISTS idx_billing_invoices_customer;
DROP INDEX IF EXISTS idx_billing_invoices_status;
DROP INDEX IF EXISTS idx_billing_invoices_org_bot;
DROP TABLE IF EXISTS billing_tax_rates;
DROP TABLE IF EXISTS billing_recurring;
DROP TABLE IF EXISTS billing_quote_items;
DROP TABLE IF EXISTS billing_quotes;
DROP TABLE IF EXISTS billing_payments;
DROP TABLE IF EXISTS billing_invoice_items;
DROP TABLE IF EXISTS billing_invoices;

View file

@ -0,0 +1,172 @@
CREATE TABLE billing_invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
invoice_number VARCHAR(50) NOT NULL,
customer_id UUID,
customer_name VARCHAR(255) NOT NULL,
customer_email VARCHAR(255),
customer_address TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
issue_date DATE NOT NULL,
due_date DATE NOT NULL,
subtotal DECIMAL(15,2) NOT NULL DEFAULT 0,
tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
discount_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
total DECIMAL(15,2) NOT NULL DEFAULT 0,
amount_paid DECIMAL(15,2) NOT NULL DEFAULT 0,
amount_due DECIMAL(15,2) NOT NULL DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
notes TEXT,
terms TEXT,
footer TEXT,
paid_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
voided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE billing_invoice_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES billing_invoices(id) ON DELETE CASCADE,
product_id UUID,
description VARCHAR(500) NOT NULL,
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
unit_price DECIMAL(15,2) NOT NULL,
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
amount DECIMAL(15,2) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE billing_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
invoice_id UUID REFERENCES billing_invoices(id) ON DELETE SET NULL,
payment_number VARCHAR(50) NOT NULL,
amount DECIMAL(15,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
payment_method VARCHAR(50) NOT NULL DEFAULT 'other',
payment_reference VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'completed',
payer_name VARCHAR(255),
payer_email VARCHAR(255),
notes TEXT,
paid_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
refunded_at TIMESTAMPTZ,
refund_amount DECIMAL(15,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE billing_quotes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
quote_number VARCHAR(50) NOT NULL,
customer_id UUID,
customer_name VARCHAR(255) NOT NULL,
customer_email VARCHAR(255),
customer_address TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
issue_date DATE NOT NULL,
valid_until DATE NOT NULL,
subtotal DECIMAL(15,2) NOT NULL DEFAULT 0,
tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
discount_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
total DECIMAL(15,2) NOT NULL DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
notes TEXT,
terms TEXT,
accepted_at TIMESTAMPTZ,
rejected_at TIMESTAMPTZ,
converted_invoice_id UUID REFERENCES billing_invoices(id) ON DELETE SET NULL,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE billing_quote_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quote_id UUID NOT NULL REFERENCES billing_quotes(id) ON DELETE CASCADE,
product_id UUID,
description VARCHAR(500) NOT NULL,
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
unit_price DECIMAL(15,2) NOT NULL,
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
amount DECIMAL(15,2) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE billing_recurring (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
customer_id UUID,
customer_name VARCHAR(255) NOT NULL,
customer_email VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'active',
frequency VARCHAR(50) NOT NULL DEFAULT 'monthly',
interval_count INTEGER NOT NULL DEFAULT 1,
amount DECIMAL(15,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
description TEXT,
next_invoice_date DATE NOT NULL,
last_invoice_date DATE,
last_invoice_id UUID REFERENCES billing_invoices(id) ON DELETE SET NULL,
start_date DATE NOT NULL,
end_date DATE,
invoices_generated INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE billing_tax_rates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
rate DECIMAL(5,2) NOT NULL,
description TEXT,
region VARCHAR(100),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_billing_invoices_org_bot ON billing_invoices(org_id, bot_id);
CREATE INDEX idx_billing_invoices_status ON billing_invoices(status);
CREATE INDEX idx_billing_invoices_customer ON billing_invoices(customer_id);
CREATE INDEX idx_billing_invoices_due_date ON billing_invoices(due_date);
CREATE INDEX idx_billing_invoices_created ON billing_invoices(created_at DESC);
CREATE UNIQUE INDEX idx_billing_invoices_number ON billing_invoices(org_id, invoice_number);
CREATE INDEX idx_billing_invoice_items_invoice ON billing_invoice_items(invoice_id);
CREATE INDEX idx_billing_payments_org_bot ON billing_payments(org_id, bot_id);
CREATE INDEX idx_billing_payments_invoice ON billing_payments(invoice_id);
CREATE INDEX idx_billing_payments_status ON billing_payments(status);
CREATE INDEX idx_billing_payments_paid_at ON billing_payments(paid_at DESC);
CREATE UNIQUE INDEX idx_billing_payments_number ON billing_payments(org_id, payment_number);
CREATE INDEX idx_billing_quotes_org_bot ON billing_quotes(org_id, bot_id);
CREATE INDEX idx_billing_quotes_status ON billing_quotes(status);
CREATE INDEX idx_billing_quotes_customer ON billing_quotes(customer_id);
CREATE INDEX idx_billing_quotes_valid_until ON billing_quotes(valid_until);
CREATE UNIQUE INDEX idx_billing_quotes_number ON billing_quotes(org_id, quote_number);
CREATE INDEX idx_billing_quote_items_quote ON billing_quote_items(quote_id);
CREATE INDEX idx_billing_recurring_org_bot ON billing_recurring(org_id, bot_id);
CREATE INDEX idx_billing_recurring_status ON billing_recurring(status);
CREATE INDEX idx_billing_recurring_next ON billing_recurring(next_invoice_date);
CREATE INDEX idx_billing_tax_rates_org_bot ON billing_tax_rates(org_id, bot_id);

View file

@ -0,0 +1,36 @@
DROP INDEX IF EXISTS idx_product_variants_sku;
DROP INDEX IF EXISTS idx_product_variants_product;
DROP INDEX IF EXISTS idx_inventory_movements_created;
DROP INDEX IF EXISTS idx_inventory_movements_product;
DROP INDEX IF EXISTS idx_inventory_movements_org_bot;
DROP INDEX IF EXISTS idx_price_list_items_service;
DROP INDEX IF EXISTS idx_price_list_items_product;
DROP INDEX IF EXISTS idx_price_list_items_list;
DROP INDEX IF EXISTS idx_price_lists_default;
DROP INDEX IF EXISTS idx_price_lists_active;
DROP INDEX IF EXISTS idx_price_lists_org_bot;
DROP INDEX IF EXISTS idx_product_categories_slug;
DROP INDEX IF EXISTS idx_product_categories_parent;
DROP INDEX IF EXISTS idx_product_categories_org_bot;
DROP INDEX IF EXISTS idx_services_active;
DROP INDEX IF EXISTS idx_services_category;
DROP INDEX IF EXISTS idx_services_org_bot;
DROP INDEX IF EXISTS idx_products_org_sku;
DROP INDEX IF EXISTS idx_products_sku;
DROP INDEX IF EXISTS idx_products_active;
DROP INDEX IF EXISTS idx_products_category;
DROP INDEX IF EXISTS idx_products_org_bot;
DROP TABLE IF EXISTS product_variants;
DROP TABLE IF EXISTS inventory_movements;
DROP TABLE IF EXISTS price_list_items;
DROP TABLE IF EXISTS price_lists;
DROP TABLE IF EXISTS product_categories;
DROP TABLE IF EXISTS services;
DROP TABLE IF EXISTS products;

View file

@ -0,0 +1,139 @@
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
sku VARCHAR(100),
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100),
product_type VARCHAR(50) NOT NULL DEFAULT 'physical',
price DECIMAL(15,2) NOT NULL DEFAULT 0,
cost DECIMAL(15,2),
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
unit VARCHAR(50) NOT NULL DEFAULT 'unit',
stock_quantity INTEGER NOT NULL DEFAULT 0,
low_stock_threshold INTEGER NOT NULL DEFAULT 10,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
images JSONB NOT NULL DEFAULT '[]',
attributes JSONB NOT NULL DEFAULT '{}',
weight DECIMAL(10,2),
dimensions JSONB,
barcode VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100),
service_type VARCHAR(50) NOT NULL DEFAULT 'hourly',
hourly_rate DECIMAL(15,2),
fixed_price DECIMAL(15,2),
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
duration_minutes INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
attributes JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE product_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
parent_id UUID REFERENCES product_categories(id) ON DELETE SET NULL,
slug VARCHAR(255),
image_url TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE price_lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
is_default BOOLEAN NOT NULL DEFAULT FALSE,
valid_from DATE,
valid_until DATE,
customer_group VARCHAR(100),
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE price_list_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
price_list_id UUID NOT NULL REFERENCES price_lists(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id) ON DELETE CASCADE,
service_id UUID REFERENCES services(id) ON DELETE CASCADE,
price DECIMAL(15,2) NOT NULL,
min_quantity INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE inventory_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
movement_type VARCHAR(50) NOT NULL,
quantity INTEGER NOT NULL,
reference_type VARCHAR(50),
reference_id UUID,
notes TEXT,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE product_variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
sku VARCHAR(100),
name VARCHAR(255) NOT NULL,
price_adjustment DECIMAL(15,2) NOT NULL DEFAULT 0,
stock_quantity INTEGER NOT NULL DEFAULT 0,
attributes JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_products_org_bot ON products(org_id, bot_id);
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_active ON products(is_active);
CREATE INDEX idx_products_sku ON products(sku);
CREATE UNIQUE INDEX idx_products_org_sku ON products(org_id, sku) WHERE sku IS NOT NULL;
CREATE INDEX idx_services_org_bot ON services(org_id, bot_id);
CREATE INDEX idx_services_category ON services(category);
CREATE INDEX idx_services_active ON services(is_active);
CREATE INDEX idx_product_categories_org_bot ON product_categories(org_id, bot_id);
CREATE INDEX idx_product_categories_parent ON product_categories(parent_id);
CREATE INDEX idx_product_categories_slug ON product_categories(slug);
CREATE INDEX idx_price_lists_org_bot ON price_lists(org_id, bot_id);
CREATE INDEX idx_price_lists_active ON price_lists(is_active);
CREATE INDEX idx_price_lists_default ON price_lists(is_default);
CREATE INDEX idx_price_list_items_list ON price_list_items(price_list_id);
CREATE INDEX idx_price_list_items_product ON price_list_items(product_id);
CREATE INDEX idx_price_list_items_service ON price_list_items(service_id);
CREATE INDEX idx_inventory_movements_org_bot ON inventory_movements(org_id, bot_id);
CREATE INDEX idx_inventory_movements_product ON inventory_movements(product_id);
CREATE INDEX idx_inventory_movements_created ON inventory_movements(created_at DESC);
CREATE INDEX idx_product_variants_product ON product_variants(product_id);
CREATE INDEX idx_product_variants_sku ON product_variants(sku);

View file

@ -0,0 +1,43 @@
DROP INDEX IF EXISTS idx_people_time_off_status;
DROP INDEX IF EXISTS idx_people_time_off_dates;
DROP INDEX IF EXISTS idx_people_time_off_person;
DROP INDEX IF EXISTS idx_people_time_off_org_bot;
DROP INDEX IF EXISTS idx_people_person_skills_skill;
DROP INDEX IF EXISTS idx_people_person_skills_person;
DROP INDEX IF EXISTS idx_people_skills_category;
DROP INDEX IF EXISTS idx_people_skills_org_bot;
DROP INDEX IF EXISTS idx_people_departments_org_code;
DROP INDEX IF EXISTS idx_people_departments_head;
DROP INDEX IF EXISTS idx_people_departments_parent;
DROP INDEX IF EXISTS idx_people_departments_org_bot;
DROP INDEX IF EXISTS idx_people_org_chart_reports_to;
DROP INDEX IF EXISTS idx_people_org_chart_person;
DROP INDEX IF EXISTS idx_people_org_chart_org;
DROP INDEX IF EXISTS idx_people_team_members_person;
DROP INDEX IF EXISTS idx_people_team_members_team;
DROP INDEX IF EXISTS idx_people_teams_leader;
DROP INDEX IF EXISTS idx_people_teams_parent;
DROP INDEX IF EXISTS idx_people_teams_org_bot;
DROP INDEX IF EXISTS idx_people_org_email;
DROP INDEX IF EXISTS idx_people_user;
DROP INDEX IF EXISTS idx_people_active;
DROP INDEX IF EXISTS idx_people_manager;
DROP INDEX IF EXISTS idx_people_department;
DROP INDEX IF EXISTS idx_people_email;
DROP INDEX IF EXISTS idx_people_org_bot;
DROP TABLE IF EXISTS people_time_off;
DROP TABLE IF EXISTS people_person_skills;
DROP TABLE IF EXISTS people_skills;
DROP TABLE IF EXISTS people_departments;
DROP TABLE IF EXISTS people_org_chart;
DROP TABLE IF EXISTS people_team_members;
DROP TABLE IF EXISTS people_teams;
DROP TABLE IF EXISTS people;

View file

@ -0,0 +1,160 @@
CREATE TABLE people (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
user_id UUID,
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(50),
mobile VARCHAR(50),
job_title VARCHAR(255),
department VARCHAR(255),
manager_id UUID REFERENCES people(id) ON DELETE SET NULL,
office_location VARCHAR(255),
hire_date DATE,
birthday DATE,
avatar_url TEXT,
bio TEXT,
skills TEXT[] NOT NULL DEFAULT '{}',
social_links JSONB NOT NULL DEFAULT '{}',
custom_fields JSONB NOT NULL DEFAULT '{}',
timezone VARCHAR(50),
locale VARCHAR(10),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE people_teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
leader_id UUID REFERENCES people(id) ON DELETE SET NULL,
parent_team_id UUID REFERENCES people_teams(id) ON DELETE SET NULL,
color VARCHAR(20),
icon VARCHAR(50),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE people_team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES people_teams(id) ON DELETE CASCADE,
person_id UUID NOT NULL REFERENCES people(id) ON DELETE CASCADE,
role VARCHAR(100),
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(team_id, person_id)
);
CREATE TABLE people_org_chart (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
person_id UUID NOT NULL REFERENCES people(id) ON DELETE CASCADE,
reports_to_id UUID REFERENCES people(id) ON DELETE SET NULL,
position_title VARCHAR(255),
position_level INTEGER NOT NULL DEFAULT 0,
position_order INTEGER NOT NULL DEFAULT 0,
effective_from DATE,
effective_until DATE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(org_id, person_id, effective_from)
);
CREATE TABLE people_departments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
code VARCHAR(50),
parent_id UUID REFERENCES people_departments(id) ON DELETE SET NULL,
head_id UUID REFERENCES people(id) ON DELETE SET NULL,
cost_center VARCHAR(50),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE people_skills (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
category VARCHAR(100),
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE people_person_skills (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES people(id) ON DELETE CASCADE,
skill_id UUID NOT NULL REFERENCES people_skills(id) ON DELETE CASCADE,
proficiency_level INTEGER NOT NULL DEFAULT 1,
years_experience DECIMAL(4,1),
verified_by UUID REFERENCES people(id) ON DELETE SET NULL,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(person_id, skill_id)
);
CREATE TABLE people_time_off (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
person_id UUID NOT NULL REFERENCES people(id) ON DELETE CASCADE,
time_off_type VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
start_date DATE NOT NULL,
end_date DATE NOT NULL,
hours_requested DECIMAL(5,1),
reason TEXT,
approved_by UUID REFERENCES people(id) ON DELETE SET NULL,
approved_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_people_org_bot ON people(org_id, bot_id);
CREATE INDEX idx_people_email ON people(email);
CREATE INDEX idx_people_department ON people(department);
CREATE INDEX idx_people_manager ON people(manager_id);
CREATE INDEX idx_people_active ON people(is_active);
CREATE INDEX idx_people_user ON people(user_id);
CREATE UNIQUE INDEX idx_people_org_email ON people(org_id, email) WHERE email IS NOT NULL;
CREATE INDEX idx_people_teams_org_bot ON people_teams(org_id, bot_id);
CREATE INDEX idx_people_teams_parent ON people_teams(parent_team_id);
CREATE INDEX idx_people_teams_leader ON people_teams(leader_id);
CREATE INDEX idx_people_team_members_team ON people_team_members(team_id);
CREATE INDEX idx_people_team_members_person ON people_team_members(person_id);
CREATE INDEX idx_people_org_chart_org ON people_org_chart(org_id, bot_id);
CREATE INDEX idx_people_org_chart_person ON people_org_chart(person_id);
CREATE INDEX idx_people_org_chart_reports_to ON people_org_chart(reports_to_id);
CREATE INDEX idx_people_departments_org_bot ON people_departments(org_id, bot_id);
CREATE INDEX idx_people_departments_parent ON people_departments(parent_id);
CREATE INDEX idx_people_departments_head ON people_departments(head_id);
CREATE UNIQUE INDEX idx_people_departments_org_code ON people_departments(org_id, code) WHERE code IS NOT NULL;
CREATE INDEX idx_people_skills_org_bot ON people_skills(org_id, bot_id);
CREATE INDEX idx_people_skills_category ON people_skills(category);
CREATE INDEX idx_people_person_skills_person ON people_person_skills(person_id);
CREATE INDEX idx_people_person_skills_skill ON people_person_skills(skill_id);
CREATE INDEX idx_people_time_off_org_bot ON people_time_off(org_id, bot_id);
CREATE INDEX idx_people_time_off_person ON people_time_off(person_id);
CREATE INDEX idx_people_time_off_dates ON people_time_off(start_date, end_date);
CREATE INDEX idx_people_time_off_status ON people_time_off(status);

View file

@ -0,0 +1,43 @@
DROP INDEX IF EXISTS idx_attendant_session_wrap_up_session;
DROP INDEX IF EXISTS idx_attendant_wrap_up_codes_org_code;
DROP INDEX IF EXISTS idx_attendant_wrap_up_codes_org_bot;
DROP INDEX IF EXISTS idx_attendant_tags_org_name;
DROP INDEX IF EXISTS idx_attendant_tags_org_bot;
DROP INDEX IF EXISTS idx_attendant_canned_shortcut;
DROP INDEX IF EXISTS idx_attendant_canned_org_bot;
DROP INDEX IF EXISTS idx_attendant_transfers_session;
DROP INDEX IF EXISTS idx_attendant_agent_status_status;
DROP INDEX IF EXISTS idx_attendant_agent_status_org;
DROP INDEX IF EXISTS idx_attendant_queue_agents_agent;
DROP INDEX IF EXISTS idx_attendant_queue_agents_queue;
DROP INDEX IF EXISTS idx_attendant_session_messages_created;
DROP INDEX IF EXISTS idx_attendant_session_messages_session;
DROP INDEX IF EXISTS idx_attendant_sessions_number;
DROP INDEX IF EXISTS idx_attendant_sessions_created;
DROP INDEX IF EXISTS idx_attendant_sessions_customer;
DROP INDEX IF EXISTS idx_attendant_sessions_queue;
DROP INDEX IF EXISTS idx_attendant_sessions_agent;
DROP INDEX IF EXISTS idx_attendant_sessions_status;
DROP INDEX IF EXISTS idx_attendant_sessions_org_bot;
DROP INDEX IF EXISTS idx_attendant_queues_active;
DROP INDEX IF EXISTS idx_attendant_queues_org_bot;
DROP TABLE IF EXISTS attendant_session_wrap_up;
DROP TABLE IF EXISTS attendant_wrap_up_codes;
DROP TABLE IF EXISTS attendant_tags;
DROP TABLE IF EXISTS attendant_canned_responses;
DROP TABLE IF EXISTS attendant_transfers;
DROP TABLE IF EXISTS attendant_agent_status;
DROP TABLE IF EXISTS attendant_queue_agents;
DROP TABLE IF EXISTS attendant_session_messages;
DROP TABLE IF EXISTS attendant_sessions;
DROP TABLE IF EXISTS attendant_queues;

View file

@ -0,0 +1,183 @@
CREATE TABLE attendant_queues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
priority INTEGER NOT NULL DEFAULT 0,
max_wait_minutes INTEGER NOT NULL DEFAULT 30,
auto_assign BOOLEAN NOT NULL DEFAULT TRUE,
working_hours JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE attendant_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
session_number VARCHAR(50) NOT NULL,
channel VARCHAR(50) NOT NULL,
customer_id UUID,
customer_name VARCHAR(255),
customer_email VARCHAR(255),
customer_phone VARCHAR(50),
status VARCHAR(50) NOT NULL DEFAULT 'waiting',
priority INTEGER NOT NULL DEFAULT 0,
agent_id UUID,
queue_id UUID REFERENCES attendant_queues(id) ON DELETE SET NULL,
subject VARCHAR(500),
initial_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_at TIMESTAMPTZ,
first_response_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
wait_time_seconds INTEGER,
handle_time_seconds INTEGER,
satisfaction_rating INTEGER,
satisfaction_comment TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
notes TEXT,
transfer_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE attendant_session_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES attendant_sessions(id) ON DELETE CASCADE,
sender_type VARCHAR(20) NOT NULL,
sender_id UUID,
sender_name VARCHAR(255),
content TEXT NOT NULL,
content_type VARCHAR(50) NOT NULL DEFAULT 'text',
attachments JSONB NOT NULL DEFAULT '[]',
is_internal BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE attendant_queue_agents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
queue_id UUID NOT NULL REFERENCES attendant_queues(id) ON DELETE CASCADE,
agent_id UUID NOT NULL,
max_concurrent INTEGER NOT NULL DEFAULT 3,
priority INTEGER NOT NULL DEFAULT 0,
skills TEXT[] NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(queue_id, agent_id)
);
CREATE TABLE attendant_agent_status (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
agent_id UUID NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'offline',
status_message VARCHAR(255),
current_sessions INTEGER NOT NULL DEFAULT 0,
max_sessions INTEGER NOT NULL DEFAULT 5,
last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
break_started_at TIMESTAMPTZ,
break_reason VARCHAR(255),
available_since TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(org_id, agent_id)
);
CREATE TABLE attendant_transfers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES attendant_sessions(id) ON DELETE CASCADE,
from_agent_id UUID,
to_agent_id UUID,
to_queue_id UUID REFERENCES attendant_queues(id) ON DELETE SET NULL,
reason VARCHAR(255),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE attendant_canned_responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
shortcut VARCHAR(50),
category VARCHAR(100),
queue_id UUID REFERENCES attendant_queues(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
usage_count INTEGER NOT NULL DEFAULT 0,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE attendant_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
color VARCHAR(20),
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE attendant_wrap_up_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
requires_notes BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE attendant_session_wrap_up (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES attendant_sessions(id) ON DELETE CASCADE,
wrap_up_code_id UUID REFERENCES attendant_wrap_up_codes(id) ON DELETE SET NULL,
notes TEXT,
follow_up_required BOOLEAN NOT NULL DEFAULT FALSE,
follow_up_date DATE,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(session_id)
);
CREATE INDEX idx_attendant_queues_org_bot ON attendant_queues(org_id, bot_id);
CREATE INDEX idx_attendant_queues_active ON attendant_queues(is_active);
CREATE INDEX idx_attendant_sessions_org_bot ON attendant_sessions(org_id, bot_id);
CREATE INDEX idx_attendant_sessions_status ON attendant_sessions(status);
CREATE INDEX idx_attendant_sessions_agent ON attendant_sessions(agent_id);
CREATE INDEX idx_attendant_sessions_queue ON attendant_sessions(queue_id);
CREATE INDEX idx_attendant_sessions_customer ON attendant_sessions(customer_id);
CREATE INDEX idx_attendant_sessions_created ON attendant_sessions(created_at DESC);
CREATE UNIQUE INDEX idx_attendant_sessions_number ON attendant_sessions(org_id, session_number);
CREATE INDEX idx_attendant_session_messages_session ON attendant_session_messages(session_id);
CREATE INDEX idx_attendant_session_messages_created ON attendant_session_messages(created_at);
CREATE INDEX idx_attendant_queue_agents_queue ON attendant_queue_agents(queue_id);
CREATE INDEX idx_attendant_queue_agents_agent ON attendant_queue_agents(agent_id);
CREATE INDEX idx_attendant_agent_status_org ON attendant_agent_status(org_id, bot_id);
CREATE INDEX idx_attendant_agent_status_status ON attendant_agent_status(status);
CREATE INDEX idx_attendant_transfers_session ON attendant_transfers(session_id);
CREATE INDEX idx_attendant_canned_org_bot ON attendant_canned_responses(org_id, bot_id);
CREATE INDEX idx_attendant_canned_shortcut ON attendant_canned_responses(shortcut);
CREATE INDEX idx_attendant_tags_org_bot ON attendant_tags(org_id, bot_id);
CREATE UNIQUE INDEX idx_attendant_tags_org_name ON attendant_tags(org_id, bot_id, name);
CREATE INDEX idx_attendant_wrap_up_codes_org_bot ON attendant_wrap_up_codes(org_id, bot_id);
CREATE UNIQUE INDEX idx_attendant_wrap_up_codes_org_code ON attendant_wrap_up_codes(org_id, bot_id, code);
CREATE INDEX idx_attendant_session_wrap_up_session ON attendant_session_wrap_up(session_id);

View file

@ -0,0 +1,26 @@
DROP INDEX IF EXISTS idx_calendar_shares_email;
DROP INDEX IF EXISTS idx_calendar_shares_user;
DROP INDEX IF EXISTS idx_calendar_shares_calendar;
DROP INDEX IF EXISTS idx_calendar_event_reminders_pending;
DROP INDEX IF EXISTS idx_calendar_event_reminders_event;
DROP INDEX IF EXISTS idx_calendar_event_attendees_email;
DROP INDEX IF EXISTS idx_calendar_event_attendees_event;
DROP INDEX IF EXISTS idx_calendar_events_recurrence;
DROP INDEX IF EXISTS idx_calendar_events_status;
DROP INDEX IF EXISTS idx_calendar_events_time_range;
DROP INDEX IF EXISTS idx_calendar_events_owner;
DROP INDEX IF EXISTS idx_calendar_events_calendar;
DROP INDEX IF EXISTS idx_calendar_events_org_bot;
DROP INDEX IF EXISTS idx_calendars_primary;
DROP INDEX IF EXISTS idx_calendars_owner;
DROP INDEX IF EXISTS idx_calendars_org_bot;
DROP TABLE IF EXISTS calendar_shares;
DROP TABLE IF EXISTS calendar_event_reminders;
DROP TABLE IF EXISTS calendar_event_attendees;
DROP TABLE IF EXISTS calendar_events;
DROP TABLE IF EXISTS calendars;

View file

@ -0,0 +1,95 @@
CREATE TABLE calendars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
owner_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
color VARCHAR(20) DEFAULT '#3b82f6',
timezone VARCHAR(100) DEFAULT 'UTC',
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
is_shared BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE calendar_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
calendar_id UUID NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
owner_id UUID NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
location VARCHAR(500),
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT FALSE,
recurrence_rule TEXT,
recurrence_id UUID REFERENCES calendar_events(id) ON DELETE SET NULL,
color VARCHAR(20),
status VARCHAR(50) NOT NULL DEFAULT 'confirmed',
visibility VARCHAR(50) NOT NULL DEFAULT 'default',
busy_status VARCHAR(50) NOT NULL DEFAULT 'busy',
reminders JSONB NOT NULL DEFAULT '[]',
attendees JSONB NOT NULL DEFAULT '[]',
conference_data JSONB,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE calendar_event_attendees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
name VARCHAR(255),
status VARCHAR(50) NOT NULL DEFAULT 'needs-action',
role VARCHAR(50) NOT NULL DEFAULT 'req-participant',
rsvp_time TIMESTAMPTZ,
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE calendar_event_reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
reminder_type VARCHAR(50) NOT NULL DEFAULT 'notification',
minutes_before INTEGER NOT NULL,
is_sent BOOLEAN NOT NULL DEFAULT FALSE,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE calendar_shares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
calendar_id UUID NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
shared_with_user_id UUID,
shared_with_email VARCHAR(255),
permission VARCHAR(50) NOT NULL DEFAULT 'read',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(calendar_id, shared_with_user_id),
UNIQUE(calendar_id, shared_with_email)
);
CREATE INDEX idx_calendars_org_bot ON calendars(org_id, bot_id);
CREATE INDEX idx_calendars_owner ON calendars(owner_id);
CREATE INDEX idx_calendars_primary ON calendars(owner_id, is_primary) WHERE is_primary = TRUE;
CREATE INDEX idx_calendar_events_org_bot ON calendar_events(org_id, bot_id);
CREATE INDEX idx_calendar_events_calendar ON calendar_events(calendar_id);
CREATE INDEX idx_calendar_events_owner ON calendar_events(owner_id);
CREATE INDEX idx_calendar_events_time_range ON calendar_events(start_time, end_time);
CREATE INDEX idx_calendar_events_status ON calendar_events(status);
CREATE INDEX idx_calendar_events_recurrence ON calendar_events(recurrence_id) WHERE recurrence_id IS NOT NULL;
CREATE INDEX idx_calendar_event_attendees_event ON calendar_event_attendees(event_id);
CREATE INDEX idx_calendar_event_attendees_email ON calendar_event_attendees(email);
CREATE INDEX idx_calendar_event_reminders_event ON calendar_event_reminders(event_id);
CREATE INDEX idx_calendar_event_reminders_pending ON calendar_event_reminders(is_sent, minutes_before) WHERE is_sent = FALSE;
CREATE INDEX idx_calendar_shares_calendar ON calendar_shares(calendar_id);
CREATE INDEX idx_calendar_shares_user ON calendar_shares(shared_with_user_id) WHERE shared_with_user_id IS NOT NULL;
CREATE INDEX idx_calendar_shares_email ON calendar_shares(shared_with_email) WHERE shared_with_email IS NOT NULL;

View file

@ -0,0 +1,43 @@
DROP INDEX IF EXISTS idx_okr_activity_created;
DROP INDEX IF EXISTS idx_okr_activity_user;
DROP INDEX IF EXISTS idx_okr_activity_key_result;
DROP INDEX IF EXISTS idx_okr_activity_objective;
DROP INDEX IF EXISTS idx_okr_activity_org_bot;
DROP INDEX IF EXISTS idx_okr_comments_parent;
DROP INDEX IF EXISTS idx_okr_comments_key_result;
DROP INDEX IF EXISTS idx_okr_comments_objective;
DROP INDEX IF EXISTS idx_okr_comments_org_bot;
DROP INDEX IF EXISTS idx_okr_templates_system;
DROP INDEX IF EXISTS idx_okr_templates_category;
DROP INDEX IF EXISTS idx_okr_templates_org_bot;
DROP INDEX IF EXISTS idx_okr_alignments_parent;
DROP INDEX IF EXISTS idx_okr_alignments_child;
DROP INDEX IF EXISTS idx_okr_alignments_org_bot;
DROP INDEX IF EXISTS idx_okr_checkins_created;
DROP INDEX IF EXISTS idx_okr_checkins_user;
DROP INDEX IF EXISTS idx_okr_checkins_key_result;
DROP INDEX IF EXISTS idx_okr_checkins_org_bot;
DROP INDEX IF EXISTS idx_okr_key_results_due_date;
DROP INDEX IF EXISTS idx_okr_key_results_status;
DROP INDEX IF EXISTS idx_okr_key_results_owner;
DROP INDEX IF EXISTS idx_okr_key_results_objective;
DROP INDEX IF EXISTS idx_okr_key_results_org_bot;
DROP INDEX IF EXISTS idx_okr_objectives_status;
DROP INDEX IF EXISTS idx_okr_objectives_period;
DROP INDEX IF EXISTS idx_okr_objectives_parent;
DROP INDEX IF EXISTS idx_okr_objectives_owner;
DROP INDEX IF EXISTS idx_okr_objectives_org_bot;
DROP TABLE IF EXISTS okr_activity_log;
DROP TABLE IF EXISTS okr_comments;
DROP TABLE IF EXISTS okr_templates;
DROP TABLE IF EXISTS okr_alignments;
DROP TABLE IF EXISTS okr_checkins;
DROP TABLE IF EXISTS okr_key_results;
DROP TABLE IF EXISTS okr_objectives;

View file

@ -0,0 +1,150 @@
CREATE TABLE okr_objectives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
owner_id UUID NOT NULL,
parent_id UUID REFERENCES okr_objectives(id) ON DELETE SET NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
period VARCHAR(50) NOT NULL,
period_start DATE,
period_end DATE,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
progress DECIMAL(5,2) NOT NULL DEFAULT 0,
visibility VARCHAR(50) NOT NULL DEFAULT 'team',
weight DECIMAL(3,2) NOT NULL DEFAULT 1.0,
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE okr_key_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
objective_id UUID NOT NULL REFERENCES okr_objectives(id) ON DELETE CASCADE,
owner_id UUID NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
metric_type VARCHAR(50) NOT NULL,
start_value DECIMAL(15,2) NOT NULL DEFAULT 0,
target_value DECIMAL(15,2) NOT NULL,
current_value DECIMAL(15,2) NOT NULL DEFAULT 0,
unit VARCHAR(50),
weight DECIMAL(3,2) NOT NULL DEFAULT 1.0,
status VARCHAR(50) NOT NULL DEFAULT 'not_started',
due_date DATE,
scoring_type VARCHAR(50) NOT NULL DEFAULT 'linear',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE okr_checkins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
key_result_id UUID NOT NULL REFERENCES okr_key_results(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
previous_value DECIMAL(15,2),
new_value DECIMAL(15,2) NOT NULL,
note TEXT,
confidence VARCHAR(50),
blockers TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE okr_alignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
child_objective_id UUID NOT NULL REFERENCES okr_objectives(id) ON DELETE CASCADE,
parent_objective_id UUID NOT NULL REFERENCES okr_objectives(id) ON DELETE CASCADE,
alignment_type VARCHAR(50) NOT NULL DEFAULT 'supports',
weight DECIMAL(3,2) NOT NULL DEFAULT 1.0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(child_objective_id, parent_objective_id)
);
CREATE TABLE okr_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100),
objective_template JSONB NOT NULL DEFAULT '{}',
key_result_templates JSONB NOT NULL DEFAULT '[]',
is_system BOOLEAN NOT NULL DEFAULT FALSE,
usage_count INTEGER NOT NULL DEFAULT 0,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE okr_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
objective_id UUID REFERENCES okr_objectives(id) ON DELETE CASCADE,
key_result_id UUID REFERENCES okr_key_results(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
content TEXT NOT NULL,
parent_comment_id UUID REFERENCES okr_comments(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT okr_comments_target_check CHECK (
(objective_id IS NOT NULL AND key_result_id IS NULL) OR
(objective_id IS NULL AND key_result_id IS NOT NULL)
)
);
CREATE TABLE okr_activity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
objective_id UUID REFERENCES okr_objectives(id) ON DELETE CASCADE,
key_result_id UUID REFERENCES okr_key_results(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
activity_type VARCHAR(50) NOT NULL,
description TEXT,
old_value TEXT,
new_value TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_okr_objectives_org_bot ON okr_objectives(org_id, bot_id);
CREATE INDEX idx_okr_objectives_owner ON okr_objectives(owner_id);
CREATE INDEX idx_okr_objectives_parent ON okr_objectives(parent_id) WHERE parent_id IS NOT NULL;
CREATE INDEX idx_okr_objectives_period ON okr_objectives(period, period_start, period_end);
CREATE INDEX idx_okr_objectives_status ON okr_objectives(status);
CREATE INDEX idx_okr_key_results_org_bot ON okr_key_results(org_id, bot_id);
CREATE INDEX idx_okr_key_results_objective ON okr_key_results(objective_id);
CREATE INDEX idx_okr_key_results_owner ON okr_key_results(owner_id);
CREATE INDEX idx_okr_key_results_status ON okr_key_results(status);
CREATE INDEX idx_okr_key_results_due_date ON okr_key_results(due_date) WHERE due_date IS NOT NULL;
CREATE INDEX idx_okr_checkins_org_bot ON okr_checkins(org_id, bot_id);
CREATE INDEX idx_okr_checkins_key_result ON okr_checkins(key_result_id);
CREATE INDEX idx_okr_checkins_user ON okr_checkins(user_id);
CREATE INDEX idx_okr_checkins_created ON okr_checkins(created_at DESC);
CREATE INDEX idx_okr_alignments_org_bot ON okr_alignments(org_id, bot_id);
CREATE INDEX idx_okr_alignments_child ON okr_alignments(child_objective_id);
CREATE INDEX idx_okr_alignments_parent ON okr_alignments(parent_objective_id);
CREATE INDEX idx_okr_templates_org_bot ON okr_templates(org_id, bot_id);
CREATE INDEX idx_okr_templates_category ON okr_templates(category);
CREATE INDEX idx_okr_templates_system ON okr_templates(is_system) WHERE is_system = TRUE;
CREATE INDEX idx_okr_comments_org_bot ON okr_comments(org_id, bot_id);
CREATE INDEX idx_okr_comments_objective ON okr_comments(objective_id) WHERE objective_id IS NOT NULL;
CREATE INDEX idx_okr_comments_key_result ON okr_comments(key_result_id) WHERE key_result_id IS NOT NULL;
CREATE INDEX idx_okr_comments_parent ON okr_comments(parent_comment_id) WHERE parent_comment_id IS NOT NULL;
CREATE INDEX idx_okr_activity_org_bot ON okr_activity_log(org_id, bot_id);
CREATE INDEX idx_okr_activity_objective ON okr_activity_log(objective_id) WHERE objective_id IS NOT NULL;
CREATE INDEX idx_okr_activity_key_result ON okr_activity_log(key_result_id) WHERE key_result_id IS NOT NULL;
CREATE INDEX idx_okr_activity_user ON okr_activity_log(user_id);
CREATE INDEX idx_okr_activity_created ON okr_activity_log(created_at DESC);

View file

@ -0,0 +1,25 @@
DROP INDEX IF EXISTS idx_canvas_comments_unresolved;
DROP INDEX IF EXISTS idx_canvas_comments_parent;
DROP INDEX IF EXISTS idx_canvas_comments_element;
DROP INDEX IF EXISTS idx_canvas_comments_canvas;
DROP INDEX IF EXISTS idx_canvas_versions_number;
DROP INDEX IF EXISTS idx_canvas_versions_canvas;
DROP INDEX IF EXISTS idx_canvas_collaborators_user;
DROP INDEX IF EXISTS idx_canvas_collaborators_canvas;
DROP INDEX IF EXISTS idx_canvas_elements_z_index;
DROP INDEX IF EXISTS idx_canvas_elements_type;
DROP INDEX IF EXISTS idx_canvas_elements_canvas;
DROP INDEX IF EXISTS idx_canvases_template;
DROP INDEX IF EXISTS idx_canvases_public;
DROP INDEX IF EXISTS idx_canvases_created_by;
DROP INDEX IF EXISTS idx_canvases_org_bot;
DROP TABLE IF EXISTS canvas_comments;
DROP TABLE IF EXISTS canvas_versions;
DROP TABLE IF EXISTS canvas_collaborators;
DROP TABLE IF EXISTS canvas_elements;
DROP TABLE IF EXISTS canvases;

View file

@ -0,0 +1,90 @@
CREATE TABLE canvases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
width INTEGER NOT NULL DEFAULT 1920,
height INTEGER NOT NULL DEFAULT 1080,
background_color VARCHAR(20) DEFAULT '#ffffff',
thumbnail_url TEXT,
is_public BOOLEAN NOT NULL DEFAULT FALSE,
is_template BOOLEAN NOT NULL DEFAULT FALSE,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE canvas_elements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
canvas_id UUID NOT NULL REFERENCES canvases(id) ON DELETE CASCADE,
element_type VARCHAR(50) NOT NULL,
x DOUBLE PRECISION NOT NULL DEFAULT 0,
y DOUBLE PRECISION NOT NULL DEFAULT 0,
width DOUBLE PRECISION NOT NULL DEFAULT 100,
height DOUBLE PRECISION NOT NULL DEFAULT 100,
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
z_index INTEGER NOT NULL DEFAULT 0,
locked BOOLEAN NOT NULL DEFAULT FALSE,
properties JSONB NOT NULL DEFAULT '{}',
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE canvas_collaborators (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
canvas_id UUID NOT NULL REFERENCES canvases(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
permission VARCHAR(50) NOT NULL DEFAULT 'view',
added_by UUID,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(canvas_id, user_id)
);
CREATE TABLE canvas_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
canvas_id UUID NOT NULL REFERENCES canvases(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
name VARCHAR(255),
elements_snapshot JSONB NOT NULL DEFAULT '[]',
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(canvas_id, version_number)
);
CREATE TABLE canvas_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
canvas_id UUID NOT NULL REFERENCES canvases(id) ON DELETE CASCADE,
element_id UUID REFERENCES canvas_elements(id) ON DELETE CASCADE,
parent_comment_id UUID REFERENCES canvas_comments(id) ON DELETE CASCADE,
author_id UUID NOT NULL,
content TEXT NOT NULL,
x_position DOUBLE PRECISION,
y_position DOUBLE PRECISION,
resolved BOOLEAN NOT NULL DEFAULT FALSE,
resolved_by UUID,
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_canvases_org_bot ON canvases(org_id, bot_id);
CREATE INDEX idx_canvases_created_by ON canvases(created_by);
CREATE INDEX idx_canvases_public ON canvases(is_public) WHERE is_public = TRUE;
CREATE INDEX idx_canvases_template ON canvases(is_template) WHERE is_template = TRUE;
CREATE INDEX idx_canvas_elements_canvas ON canvas_elements(canvas_id);
CREATE INDEX idx_canvas_elements_type ON canvas_elements(element_type);
CREATE INDEX idx_canvas_elements_z_index ON canvas_elements(canvas_id, z_index);
CREATE INDEX idx_canvas_collaborators_canvas ON canvas_collaborators(canvas_id);
CREATE INDEX idx_canvas_collaborators_user ON canvas_collaborators(user_id);
CREATE INDEX idx_canvas_versions_canvas ON canvas_versions(canvas_id);
CREATE INDEX idx_canvas_versions_number ON canvas_versions(canvas_id, version_number DESC);
CREATE INDEX idx_canvas_comments_canvas ON canvas_comments(canvas_id);
CREATE INDEX idx_canvas_comments_element ON canvas_comments(element_id) WHERE element_id IS NOT NULL;
CREATE INDEX idx_canvas_comments_parent ON canvas_comments(parent_comment_id) WHERE parent_comment_id IS NOT NULL;
CREATE INDEX idx_canvas_comments_unresolved ON canvas_comments(canvas_id, resolved) WHERE resolved = FALSE;

View file

@ -0,0 +1,39 @@
DROP INDEX IF EXISTS idx_workspace_templates_system;
DROP INDEX IF EXISTS idx_workspace_templates_category;
DROP INDEX IF EXISTS idx_workspace_templates_org_bot;
DROP INDEX IF EXISTS idx_workspace_comment_reactions_comment;
DROP INDEX IF EXISTS idx_workspace_comments_unresolved;
DROP INDEX IF EXISTS idx_workspace_comments_parent;
DROP INDEX IF EXISTS idx_workspace_comments_block;
DROP INDEX IF EXISTS idx_workspace_comments_page;
DROP INDEX IF EXISTS idx_workspace_comments_workspace;
DROP INDEX IF EXISTS idx_workspace_page_permissions_user;
DROP INDEX IF EXISTS idx_workspace_page_permissions_page;
DROP INDEX IF EXISTS idx_workspace_page_versions_number;
DROP INDEX IF EXISTS idx_workspace_page_versions_page;
DROP INDEX IF EXISTS idx_workspace_pages_position;
DROP INDEX IF EXISTS idx_workspace_pages_public;
DROP INDEX IF EXISTS idx_workspace_pages_template;
DROP INDEX IF EXISTS idx_workspace_pages_parent;
DROP INDEX IF EXISTS idx_workspace_pages_workspace;
DROP INDEX IF EXISTS idx_workspace_members_role;
DROP INDEX IF EXISTS idx_workspace_members_user;
DROP INDEX IF EXISTS idx_workspace_members_workspace;
DROP INDEX IF EXISTS idx_workspaces_created_by;
DROP INDEX IF EXISTS idx_workspaces_org_bot;
DROP TABLE IF EXISTS workspace_templates;
DROP TABLE IF EXISTS workspace_comment_reactions;
DROP TABLE IF EXISTS workspace_comments;
DROP TABLE IF EXISTS workspace_page_permissions;
DROP TABLE IF EXISTS workspace_page_versions;
DROP TABLE IF EXISTS workspace_pages;
DROP TABLE IF EXISTS workspace_members;
DROP TABLE IF EXISTS workspaces;

View file

@ -0,0 +1,141 @@
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
icon_type VARCHAR(20) DEFAULT 'emoji',
icon_value VARCHAR(100),
cover_image TEXT,
settings JSONB NOT NULL DEFAULT '{}',
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE workspace_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'member',
invited_by UUID,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(workspace_id, user_id)
);
CREATE TABLE workspace_pages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
parent_id UUID REFERENCES workspace_pages(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
icon_type VARCHAR(20),
icon_value VARCHAR(100),
cover_image TEXT,
content JSONB NOT NULL DEFAULT '[]',
properties JSONB NOT NULL DEFAULT '{}',
is_template BOOLEAN NOT NULL DEFAULT FALSE,
template_id UUID REFERENCES workspace_pages(id) ON DELETE SET NULL,
is_public BOOLEAN NOT NULL DEFAULT FALSE,
public_edit BOOLEAN NOT NULL DEFAULT FALSE,
position INTEGER NOT NULL DEFAULT 0,
created_by UUID NOT NULL,
last_edited_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE workspace_page_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_id UUID NOT NULL REFERENCES workspace_pages(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
title VARCHAR(500) NOT NULL,
content JSONB NOT NULL DEFAULT '[]',
change_summary TEXT,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(page_id, version_number)
);
CREATE TABLE workspace_page_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_id UUID NOT NULL REFERENCES workspace_pages(id) ON DELETE CASCADE,
user_id UUID,
role VARCHAR(50),
permission VARCHAR(50) NOT NULL DEFAULT 'view',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(page_id, user_id),
UNIQUE(page_id, role)
);
CREATE TABLE workspace_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
page_id UUID NOT NULL REFERENCES workspace_pages(id) ON DELETE CASCADE,
block_id UUID,
parent_comment_id UUID REFERENCES workspace_comments(id) ON DELETE CASCADE,
author_id UUID NOT NULL,
content TEXT NOT NULL,
resolved BOOLEAN NOT NULL DEFAULT FALSE,
resolved_by UUID,
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE workspace_comment_reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
comment_id UUID NOT NULL REFERENCES workspace_comments(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
emoji VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(comment_id, user_id, emoji)
);
CREATE TABLE workspace_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100),
icon_type VARCHAR(20),
icon_value VARCHAR(100),
cover_image TEXT,
content JSONB NOT NULL DEFAULT '[]',
is_system BOOLEAN NOT NULL DEFAULT FALSE,
usage_count INTEGER NOT NULL DEFAULT 0,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_workspaces_org_bot ON workspaces(org_id, bot_id);
CREATE INDEX idx_workspaces_created_by ON workspaces(created_by);
CREATE INDEX idx_workspace_members_workspace ON workspace_members(workspace_id);
CREATE INDEX idx_workspace_members_user ON workspace_members(user_id);
CREATE INDEX idx_workspace_members_role ON workspace_members(role);
CREATE INDEX idx_workspace_pages_workspace ON workspace_pages(workspace_id);
CREATE INDEX idx_workspace_pages_parent ON workspace_pages(parent_id) WHERE parent_id IS NOT NULL;
CREATE INDEX idx_workspace_pages_template ON workspace_pages(is_template) WHERE is_template = TRUE;
CREATE INDEX idx_workspace_pages_public ON workspace_pages(is_public) WHERE is_public = TRUE;
CREATE INDEX idx_workspace_pages_position ON workspace_pages(workspace_id, parent_id, position);
CREATE INDEX idx_workspace_page_versions_page ON workspace_page_versions(page_id);
CREATE INDEX idx_workspace_page_versions_number ON workspace_page_versions(page_id, version_number DESC);
CREATE INDEX idx_workspace_page_permissions_page ON workspace_page_permissions(page_id);
CREATE INDEX idx_workspace_page_permissions_user ON workspace_page_permissions(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_workspace_comments_workspace ON workspace_comments(workspace_id);
CREATE INDEX idx_workspace_comments_page ON workspace_comments(page_id);
CREATE INDEX idx_workspace_comments_block ON workspace_comments(block_id) WHERE block_id IS NOT NULL;
CREATE INDEX idx_workspace_comments_parent ON workspace_comments(parent_comment_id) WHERE parent_comment_id IS NOT NULL;
CREATE INDEX idx_workspace_comments_unresolved ON workspace_comments(page_id, resolved) WHERE resolved = FALSE;
CREATE INDEX idx_workspace_comment_reactions_comment ON workspace_comment_reactions(comment_id);
CREATE INDEX idx_workspace_templates_org_bot ON workspace_templates(org_id, bot_id);
CREATE INDEX idx_workspace_templates_category ON workspace_templates(category);
CREATE INDEX idx_workspace_templates_system ON workspace_templates(is_system) WHERE is_system = TRUE;

View file

@ -0,0 +1,63 @@
DROP INDEX IF EXISTS idx_social_hashtags_popular;
DROP INDEX IF EXISTS idx_social_hashtags_tag;
DROP INDEX IF EXISTS idx_social_hashtags_org_bot;
DROP INDEX IF EXISTS idx_social_bookmarks_post;
DROP INDEX IF EXISTS idx_social_bookmarks_user;
DROP INDEX IF EXISTS idx_social_praises_created;
DROP INDEX IF EXISTS idx_social_praises_to;
DROP INDEX IF EXISTS idx_social_praises_from;
DROP INDEX IF EXISTS idx_social_praises_org_bot;
DROP INDEX IF EXISTS idx_social_announcements_pinned;
DROP INDEX IF EXISTS idx_social_announcements_priority;
DROP INDEX IF EXISTS idx_social_announcements_active;
DROP INDEX IF EXISTS idx_social_announcements_org_bot;
DROP INDEX IF EXISTS idx_social_poll_votes_user;
DROP INDEX IF EXISTS idx_social_poll_votes_poll;
DROP INDEX IF EXISTS idx_social_poll_options_poll;
DROP INDEX IF EXISTS idx_social_polls_post;
DROP INDEX IF EXISTS idx_social_reactions_user;
DROP INDEX IF EXISTS idx_social_reactions_comment;
DROP INDEX IF EXISTS idx_social_reactions_post;
DROP INDEX IF EXISTS idx_social_comments_created;
DROP INDEX IF EXISTS idx_social_comments_author;
DROP INDEX IF EXISTS idx_social_comments_parent;
DROP INDEX IF EXISTS idx_social_comments_post;
DROP INDEX IF EXISTS idx_social_posts_hashtags;
DROP INDEX IF EXISTS idx_social_posts_created;
DROP INDEX IF EXISTS idx_social_posts_announcement;
DROP INDEX IF EXISTS idx_social_posts_pinned;
DROP INDEX IF EXISTS idx_social_posts_visibility;
DROP INDEX IF EXISTS idx_social_posts_parent;
DROP INDEX IF EXISTS idx_social_posts_community;
DROP INDEX IF EXISTS idx_social_posts_author;
DROP INDEX IF EXISTS idx_social_posts_org_bot;
DROP INDEX IF EXISTS idx_social_community_members_role;
DROP INDEX IF EXISTS idx_social_community_members_user;
DROP INDEX IF EXISTS idx_social_community_members_community;
DROP INDEX IF EXISTS idx_social_communities_owner;
DROP INDEX IF EXISTS idx_social_communities_featured;
DROP INDEX IF EXISTS idx_social_communities_visibility;
DROP INDEX IF EXISTS idx_social_communities_slug;
DROP INDEX IF EXISTS idx_social_communities_org_bot;
DROP TABLE IF EXISTS social_hashtags;
DROP TABLE IF EXISTS social_bookmarks;
DROP TABLE IF EXISTS social_praises;
DROP TABLE IF EXISTS social_announcements;
DROP TABLE IF EXISTS social_poll_votes;
DROP TABLE IF EXISTS social_poll_options;
DROP TABLE IF EXISTS social_polls;
DROP TABLE IF EXISTS social_reactions;
DROP TABLE IF EXISTS social_comments;
DROP TABLE IF EXISTS social_posts;
DROP TABLE IF EXISTS social_community_members;
DROP TABLE IF EXISTS social_communities;

View file

@ -0,0 +1,219 @@
CREATE TABLE social_communities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
cover_image TEXT,
icon TEXT,
visibility VARCHAR(50) NOT NULL DEFAULT 'public',
join_policy VARCHAR(50) NOT NULL DEFAULT 'open',
owner_id UUID NOT NULL,
member_count INTEGER NOT NULL DEFAULT 0,
post_count INTEGER NOT NULL DEFAULT 0,
is_official BOOLEAN NOT NULL DEFAULT FALSE,
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
archived_at TIMESTAMPTZ
);
CREATE TABLE social_community_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
community_id UUID NOT NULL REFERENCES social_communities(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'member',
notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ,
UNIQUE(community_id, user_id)
);
CREATE TABLE social_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
author_id UUID NOT NULL,
community_id UUID REFERENCES social_communities(id) ON DELETE CASCADE,
parent_id UUID REFERENCES social_posts(id) ON DELETE CASCADE,
content TEXT NOT NULL,
content_type VARCHAR(50) NOT NULL DEFAULT 'text',
attachments JSONB NOT NULL DEFAULT '[]',
mentions JSONB NOT NULL DEFAULT '[]',
hashtags TEXT[] NOT NULL DEFAULT '{}',
visibility VARCHAR(50) NOT NULL DEFAULT 'public',
is_announcement BOOLEAN NOT NULL DEFAULT FALSE,
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
poll_id UUID,
reaction_counts JSONB NOT NULL DEFAULT '{}',
comment_count INTEGER NOT NULL DEFAULT 0,
share_count INTEGER NOT NULL DEFAULT 0,
view_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);
CREATE TABLE social_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES social_posts(id) ON DELETE CASCADE,
parent_comment_id UUID REFERENCES social_comments(id) ON DELETE CASCADE,
author_id UUID NOT NULL,
content TEXT NOT NULL,
mentions JSONB NOT NULL DEFAULT '[]',
reaction_counts JSONB NOT NULL DEFAULT '{}',
reply_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ
);
CREATE TABLE social_reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID REFERENCES social_posts(id) ON DELETE CASCADE,
comment_id UUID REFERENCES social_comments(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
reaction_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT social_reactions_target_check CHECK (
(post_id IS NOT NULL AND comment_id IS NULL) OR
(post_id IS NULL AND comment_id IS NOT NULL)
),
UNIQUE(post_id, user_id, reaction_type),
UNIQUE(comment_id, user_id, reaction_type)
);
CREATE TABLE social_polls (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES social_posts(id) ON DELETE CASCADE,
question TEXT NOT NULL,
allow_multiple BOOLEAN NOT NULL DEFAULT FALSE,
allow_add_options BOOLEAN NOT NULL DEFAULT FALSE,
anonymous BOOLEAN NOT NULL DEFAULT FALSE,
total_votes INTEGER NOT NULL DEFAULT 0,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE social_poll_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
poll_id UUID NOT NULL REFERENCES social_polls(id) ON DELETE CASCADE,
text VARCHAR(500) NOT NULL,
vote_count INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE social_poll_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
poll_id UUID NOT NULL REFERENCES social_polls(id) ON DELETE CASCADE,
option_id UUID NOT NULL REFERENCES social_poll_options(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(poll_id, option_id, user_id)
);
CREATE TABLE social_announcements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
author_id UUID NOT NULL,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
priority VARCHAR(50) NOT NULL DEFAULT 'normal',
target_audience JSONB NOT NULL DEFAULT '{}',
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
requires_acknowledgment BOOLEAN NOT NULL DEFAULT FALSE,
acknowledged_by JSONB NOT NULL DEFAULT '[]',
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE social_praises (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
from_user_id UUID NOT NULL,
to_user_id UUID NOT NULL,
badge_type VARCHAR(50) NOT NULL,
message TEXT,
is_public BOOLEAN NOT NULL DEFAULT TRUE,
post_id UUID REFERENCES social_posts(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE social_bookmarks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
post_id UUID NOT NULL REFERENCES social_posts(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, post_id)
);
CREATE TABLE social_hashtags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
tag VARCHAR(100) NOT NULL,
post_count INTEGER NOT NULL DEFAULT 0,
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(org_id, bot_id, tag)
);
CREATE INDEX idx_social_communities_org_bot ON social_communities(org_id, bot_id);
CREATE INDEX idx_social_communities_slug ON social_communities(slug);
CREATE INDEX idx_social_communities_visibility ON social_communities(visibility);
CREATE INDEX idx_social_communities_featured ON social_communities(is_featured) WHERE is_featured = TRUE;
CREATE INDEX idx_social_communities_owner ON social_communities(owner_id);
CREATE INDEX idx_social_community_members_community ON social_community_members(community_id);
CREATE INDEX idx_social_community_members_user ON social_community_members(user_id);
CREATE INDEX idx_social_community_members_role ON social_community_members(community_id, role);
CREATE INDEX idx_social_posts_org_bot ON social_posts(org_id, bot_id);
CREATE INDEX idx_social_posts_author ON social_posts(author_id);
CREATE INDEX idx_social_posts_community ON social_posts(community_id) WHERE community_id IS NOT NULL;
CREATE INDEX idx_social_posts_parent ON social_posts(parent_id) WHERE parent_id IS NOT NULL;
CREATE INDEX idx_social_posts_visibility ON social_posts(visibility);
CREATE INDEX idx_social_posts_pinned ON social_posts(community_id, is_pinned) WHERE is_pinned = TRUE;
CREATE INDEX idx_social_posts_announcement ON social_posts(is_announcement) WHERE is_announcement = TRUE;
CREATE INDEX idx_social_posts_created ON social_posts(created_at DESC);
CREATE INDEX idx_social_posts_hashtags ON social_posts USING GIN(hashtags);
CREATE INDEX idx_social_comments_post ON social_comments(post_id);
CREATE INDEX idx_social_comments_parent ON social_comments(parent_comment_id) WHERE parent_comment_id IS NOT NULL;
CREATE INDEX idx_social_comments_author ON social_comments(author_id);
CREATE INDEX idx_social_comments_created ON social_comments(created_at DESC);
CREATE INDEX idx_social_reactions_post ON social_reactions(post_id) WHERE post_id IS NOT NULL;
CREATE INDEX idx_social_reactions_comment ON social_reactions(comment_id) WHERE comment_id IS NOT NULL;
CREATE INDEX idx_social_reactions_user ON social_reactions(user_id);
CREATE INDEX idx_social_polls_post ON social_polls(post_id);
CREATE INDEX idx_social_poll_options_poll ON social_poll_options(poll_id);
CREATE INDEX idx_social_poll_votes_poll ON social_poll_votes(poll_id);
CREATE INDEX idx_social_poll_votes_user ON social_poll_votes(user_id);
CREATE INDEX idx_social_announcements_org_bot ON social_announcements(org_id, bot_id);
CREATE INDEX idx_social_announcements_active ON social_announcements(starts_at, ends_at);
CREATE INDEX idx_social_announcements_priority ON social_announcements(priority);
CREATE INDEX idx_social_announcements_pinned ON social_announcements(is_pinned) WHERE is_pinned = TRUE;
CREATE INDEX idx_social_praises_org_bot ON social_praises(org_id, bot_id);
CREATE INDEX idx_social_praises_from ON social_praises(from_user_id);
CREATE INDEX idx_social_praises_to ON social_praises(to_user_id);
CREATE INDEX idx_social_praises_created ON social_praises(created_at DESC);
CREATE INDEX idx_social_bookmarks_user ON social_bookmarks(user_id);
CREATE INDEX idx_social_bookmarks_post ON social_bookmarks(post_id);
CREATE INDEX idx_social_hashtags_org_bot ON social_hashtags(org_id, bot_id);
CREATE INDEX idx_social_hashtags_tag ON social_hashtags(tag);
CREATE INDEX idx_social_hashtags_popular ON social_hashtags(org_id, bot_id, post_count DESC);

View file

@ -0,0 +1,34 @@
DROP INDEX IF EXISTS idx_research_exports_status;
DROP INDEX IF EXISTS idx_research_exports_project;
DROP INDEX IF EXISTS idx_research_collaborators_user;
DROP INDEX IF EXISTS idx_research_collaborators_project;
DROP INDEX IF EXISTS idx_research_citations_style;
DROP INDEX IF EXISTS idx_research_citations_source;
DROP INDEX IF EXISTS idx_research_findings_status;
DROP INDEX IF EXISTS idx_research_findings_type;
DROP INDEX IF EXISTS idx_research_findings_project;
DROP INDEX IF EXISTS idx_research_notes_tags;
DROP INDEX IF EXISTS idx_research_notes_type;
DROP INDEX IF EXISTS idx_research_notes_source;
DROP INDEX IF EXISTS idx_research_notes_project;
DROP INDEX IF EXISTS idx_research_sources_verified;
DROP INDEX IF EXISTS idx_research_sources_type;
DROP INDEX IF EXISTS idx_research_sources_project;
DROP INDEX IF EXISTS idx_research_projects_tags;
DROP INDEX IF EXISTS idx_research_projects_status;
DROP INDEX IF EXISTS idx_research_projects_owner;
DROP INDEX IF EXISTS idx_research_projects_org_bot;
DROP TABLE IF EXISTS research_exports;
DROP TABLE IF EXISTS research_collaborators;
DROP TABLE IF EXISTS research_citations;
DROP TABLE IF EXISTS research_findings;
DROP TABLE IF EXISTS research_notes;
DROP TABLE IF EXISTS research_sources;
DROP TABLE IF EXISTS research_projects;

View file

@ -0,0 +1,118 @@
CREATE TABLE research_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'active',
owner_id UUID NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE research_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
source_type VARCHAR(50) NOT NULL,
name VARCHAR(500) NOT NULL,
url TEXT,
content TEXT,
summary TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
credibility_score INTEGER,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
added_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE research_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
source_id UUID REFERENCES research_sources(id) ON DELETE SET NULL,
title VARCHAR(500),
content TEXT NOT NULL,
note_type VARCHAR(50) NOT NULL DEFAULT 'general',
tags TEXT[] NOT NULL DEFAULT '{}',
highlight_text TEXT,
highlight_position JSONB,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE research_findings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
finding_type VARCHAR(50) NOT NULL DEFAULT 'insight',
confidence_level VARCHAR(50),
supporting_sources JSONB NOT NULL DEFAULT '[]',
related_findings JSONB NOT NULL DEFAULT '[]',
status VARCHAR(50) NOT NULL DEFAULT 'draft',
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE research_citations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES research_sources(id) ON DELETE CASCADE,
citation_style VARCHAR(50) NOT NULL DEFAULT 'apa',
formatted_citation TEXT NOT NULL,
bibtex TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE research_collaborators (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'viewer',
invited_by UUID,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(project_id, user_id)
);
CREATE TABLE research_exports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES research_projects(id) ON DELETE CASCADE,
export_type VARCHAR(50) NOT NULL,
format VARCHAR(50) NOT NULL,
file_url TEXT,
file_size INTEGER,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_research_projects_org_bot ON research_projects(org_id, bot_id);
CREATE INDEX idx_research_projects_owner ON research_projects(owner_id);
CREATE INDEX idx_research_projects_status ON research_projects(status);
CREATE INDEX idx_research_projects_tags ON research_projects USING GIN(tags);
CREATE INDEX idx_research_sources_project ON research_sources(project_id);
CREATE INDEX idx_research_sources_type ON research_sources(source_type);
CREATE INDEX idx_research_sources_verified ON research_sources(is_verified) WHERE is_verified = TRUE;
CREATE INDEX idx_research_notes_project ON research_notes(project_id);
CREATE INDEX idx_research_notes_source ON research_notes(source_id) WHERE source_id IS NOT NULL;
CREATE INDEX idx_research_notes_type ON research_notes(note_type);
CREATE INDEX idx_research_notes_tags ON research_notes USING GIN(tags);
CREATE INDEX idx_research_findings_project ON research_findings(project_id);
CREATE INDEX idx_research_findings_type ON research_findings(finding_type);
CREATE INDEX idx_research_findings_status ON research_findings(status);
CREATE INDEX idx_research_citations_source ON research_citations(source_id);
CREATE INDEX idx_research_citations_style ON research_citations(citation_style);
CREATE INDEX idx_research_collaborators_project ON research_collaborators(project_id);
CREATE INDEX idx_research_collaborators_user ON research_collaborators(user_id);
CREATE INDEX idx_research_exports_project ON research_exports(project_id);
CREATE INDEX idx_research_exports_status ON research_exports(status);

View file

@ -0,0 +1,13 @@
DROP INDEX IF EXISTS idx_dashboard_filters_dashboard;
DROP INDEX IF EXISTS idx_dashboard_data_sources_dashboard;
DROP INDEX IF EXISTS idx_dashboard_data_sources_org_bot;
DROP INDEX IF EXISTS idx_dashboard_widgets_dashboard;
DROP INDEX IF EXISTS idx_dashboards_template;
DROP INDEX IF EXISTS idx_dashboards_public;
DROP INDEX IF EXISTS idx_dashboards_owner;
DROP INDEX IF EXISTS idx_dashboards_org_bot;
DROP TABLE IF EXISTS dashboard_filters;
DROP TABLE IF EXISTS dashboard_widgets;
DROP TABLE IF EXISTS dashboard_data_sources;
DROP TABLE IF EXISTS dashboards;

View file

@ -0,0 +1,100 @@
CREATE TABLE dashboards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
owner_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
layout JSONB NOT NULL DEFAULT '{"columns": 12, "row_height": 80, "gap": 16}',
refresh_interval INTEGER,
is_public BOOLEAN NOT NULL DEFAULT FALSE,
is_template BOOLEAN NOT NULL DEFAULT FALSE,
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE dashboard_widgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
widget_type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
position_x INTEGER NOT NULL DEFAULT 0,
position_y INTEGER NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 4,
height INTEGER NOT NULL DEFAULT 3,
config JSONB NOT NULL DEFAULT '{}',
data_query JSONB,
style JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE dashboard_data_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
source_type VARCHAR(50) NOT NULL,
connection JSONB NOT NULL DEFAULT '{}',
schema_definition JSONB NOT NULL DEFAULT '{}',
refresh_schedule VARCHAR(100),
last_sync TIMESTAMPTZ,
status VARCHAR(50) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE dashboard_filters (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
field VARCHAR(255) NOT NULL,
filter_type VARCHAR(50) NOT NULL,
default_value JSONB,
options JSONB NOT NULL DEFAULT '[]',
linked_widgets JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE dashboard_widget_data_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
widget_id UUID NOT NULL REFERENCES dashboard_widgets(id) ON DELETE CASCADE,
data_source_id UUID NOT NULL REFERENCES dashboard_data_sources(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(widget_id, data_source_id)
);
CREATE TABLE conversational_queries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
dashboard_id UUID REFERENCES dashboards(id) ON DELETE SET NULL,
user_id UUID NOT NULL,
natural_language TEXT NOT NULL,
generated_query TEXT,
result_widget_config JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_dashboards_org_bot ON dashboards(org_id, bot_id);
CREATE INDEX idx_dashboards_owner ON dashboards(owner_id);
CREATE INDEX idx_dashboards_is_public ON dashboards(is_public) WHERE is_public = TRUE;
CREATE INDEX idx_dashboards_is_template ON dashboards(is_template) WHERE is_template = TRUE;
CREATE INDEX idx_dashboards_tags ON dashboards USING GIN(tags);
CREATE INDEX idx_dashboards_created ON dashboards(created_at DESC);
CREATE INDEX idx_dashboard_widgets_dashboard ON dashboard_widgets(dashboard_id);
CREATE INDEX idx_dashboard_widgets_type ON dashboard_widgets(widget_type);
CREATE INDEX idx_dashboard_data_sources_org_bot ON dashboard_data_sources(org_id, bot_id);
CREATE INDEX idx_dashboard_data_sources_type ON dashboard_data_sources(source_type);
CREATE INDEX idx_dashboard_data_sources_status ON dashboard_data_sources(status);
CREATE INDEX idx_dashboard_filters_dashboard ON dashboard_filters(dashboard_id);
CREATE INDEX idx_conversational_queries_org_bot ON conversational_queries(org_id, bot_id);
CREATE INDEX idx_conversational_queries_dashboard ON conversational_queries(dashboard_id) WHERE dashboard_id IS NOT NULL;
CREATE INDEX idx_conversational_queries_user ON conversational_queries(user_id);
CREATE INDEX idx_conversational_queries_created ON conversational_queries(created_at DESC);

View file

@ -0,0 +1,12 @@
DROP INDEX IF EXISTS idx_consent_history_consent;
DROP INDEX IF EXISTS idx_consent_history_created;
DROP INDEX IF EXISTS idx_cookie_consents_user;
DROP INDEX IF EXISTS idx_cookie_consents_session;
DROP INDEX IF EXISTS idx_cookie_consents_org_bot;
DROP INDEX IF EXISTS idx_legal_documents_org_bot;
DROP INDEX IF EXISTS idx_legal_documents_slug;
DROP INDEX IF EXISTS idx_legal_documents_type;
DROP TABLE IF EXISTS consent_history;
DROP TABLE IF EXISTS cookie_consents;
DROP TABLE IF EXISTS legal_documents;

View file

@ -0,0 +1,140 @@
CREATE TABLE legal_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
slug VARCHAR(100) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
document_type VARCHAR(50) NOT NULL,
version VARCHAR(50) NOT NULL DEFAULT '1.0.0',
effective_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
requires_acceptance BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB NOT NULL DEFAULT '{}',
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(org_id, bot_id, slug, version)
);
CREATE TABLE legal_document_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES legal_documents(id) ON DELETE CASCADE,
version VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
change_summary TEXT,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE cookie_consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
user_id UUID,
session_id VARCHAR(255),
ip_address VARCHAR(45),
user_agent TEXT,
country_code VARCHAR(2),
consent_necessary BOOLEAN NOT NULL DEFAULT TRUE,
consent_analytics BOOLEAN NOT NULL DEFAULT FALSE,
consent_marketing BOOLEAN NOT NULL DEFAULT FALSE,
consent_preferences BOOLEAN NOT NULL DEFAULT FALSE,
consent_functional BOOLEAN NOT NULL DEFAULT FALSE,
consent_version VARCHAR(50) NOT NULL,
consent_given_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
consent_updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
consent_withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE consent_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
consent_id UUID NOT NULL REFERENCES cookie_consents(id) ON DELETE CASCADE,
action VARCHAR(50) NOT NULL,
previous_consents JSONB NOT NULL DEFAULT '{}',
new_consents JSONB NOT NULL DEFAULT '{}',
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE legal_acceptances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
user_id UUID NOT NULL,
document_id UUID NOT NULL REFERENCES legal_documents(id) ON DELETE CASCADE,
document_version VARCHAR(50) NOT NULL,
accepted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address VARCHAR(45),
user_agent TEXT,
UNIQUE(user_id, document_id, document_version)
);
CREATE TABLE data_deletion_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
user_id UUID NOT NULL,
request_type VARCHAR(50) NOT NULL DEFAULT 'full',
status VARCHAR(50) NOT NULL DEFAULT 'pending',
reason TEXT,
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
scheduled_for TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
confirmation_token VARCHAR(255) NOT NULL,
confirmed_at TIMESTAMPTZ,
processed_by UUID,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE data_export_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
user_id UUID NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
format VARCHAR(50) NOT NULL DEFAULT 'json',
include_sections JSONB NOT NULL DEFAULT '["all"]',
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
file_url TEXT,
file_size INTEGER,
expires_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_legal_documents_org_bot ON legal_documents(org_id, bot_id);
CREATE INDEX idx_legal_documents_slug ON legal_documents(org_id, bot_id, slug);
CREATE INDEX idx_legal_documents_type ON legal_documents(document_type);
CREATE INDEX idx_legal_documents_active ON legal_documents(is_active) WHERE is_active = TRUE;
CREATE INDEX idx_legal_document_versions_document ON legal_document_versions(document_id);
CREATE INDEX idx_legal_document_versions_version ON legal_document_versions(document_id, version);
CREATE INDEX idx_cookie_consents_org_bot ON cookie_consents(org_id, bot_id);
CREATE INDEX idx_cookie_consents_user ON cookie_consents(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_cookie_consents_session ON cookie_consents(session_id) WHERE session_id IS NOT NULL;
CREATE INDEX idx_cookie_consents_given ON cookie_consents(consent_given_at DESC);
CREATE INDEX idx_consent_history_consent ON consent_history(consent_id);
CREATE INDEX idx_consent_history_action ON consent_history(action);
CREATE INDEX idx_consent_history_created ON consent_history(created_at DESC);
CREATE INDEX idx_legal_acceptances_org_bot ON legal_acceptances(org_id, bot_id);
CREATE INDEX idx_legal_acceptances_user ON legal_acceptances(user_id);
CREATE INDEX idx_legal_acceptances_document ON legal_acceptances(document_id);
CREATE INDEX idx_data_deletion_requests_org_bot ON data_deletion_requests(org_id, bot_id);
CREATE INDEX idx_data_deletion_requests_user ON data_deletion_requests(user_id);
CREATE INDEX idx_data_deletion_requests_status ON data_deletion_requests(status);
CREATE INDEX idx_data_deletion_requests_token ON data_deletion_requests(confirmation_token);
CREATE INDEX idx_data_export_requests_org_bot ON data_export_requests(org_id, bot_id);
CREATE INDEX idx_data_export_requests_user ON data_export_requests(user_id);
CREATE INDEX idx_data_export_requests_status ON data_export_requests(status);

View file

@ -0,0 +1,18 @@
DROP INDEX IF EXISTS idx_compliance_evidence_check;
DROP INDEX IF EXISTS idx_compliance_evidence_org_bot;
DROP INDEX IF EXISTS idx_audit_log_created;
DROP INDEX IF EXISTS idx_audit_log_resource;
DROP INDEX IF EXISTS idx_audit_log_user;
DROP INDEX IF EXISTS idx_audit_log_org_bot;
DROP INDEX IF EXISTS idx_compliance_issues_status;
DROP INDEX IF EXISTS idx_compliance_issues_severity;
DROP INDEX IF EXISTS idx_compliance_issues_check;
DROP INDEX IF EXISTS idx_compliance_issues_org_bot;
DROP INDEX IF EXISTS idx_compliance_checks_status;
DROP INDEX IF EXISTS idx_compliance_checks_framework;
DROP INDEX IF EXISTS idx_compliance_checks_org_bot;
DROP TABLE IF EXISTS compliance_evidence;
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS compliance_issues;
DROP TABLE IF EXISTS compliance_checks;

View file

@ -0,0 +1,182 @@
CREATE TABLE compliance_checks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
framework VARCHAR(50) NOT NULL,
control_id VARCHAR(100) NOT NULL,
control_name VARCHAR(500) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
score NUMERIC(5,2) NOT NULL DEFAULT 0,
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
checked_by UUID,
evidence JSONB NOT NULL DEFAULT '[]',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE compliance_issues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
check_id UUID REFERENCES compliance_checks(id) ON DELETE CASCADE,
severity VARCHAR(50) NOT NULL DEFAULT 'medium',
title VARCHAR(500) NOT NULL,
description TEXT NOT NULL,
remediation TEXT,
due_date TIMESTAMPTZ,
assigned_to UUID,
status VARCHAR(50) NOT NULL DEFAULT 'open',
resolved_at TIMESTAMPTZ,
resolved_by UUID,
resolution_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE compliance_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
event_type VARCHAR(100) NOT NULL,
user_id UUID,
resource_type VARCHAR(100) NOT NULL,
resource_id VARCHAR(255) NOT NULL,
action VARCHAR(100) NOT NULL,
result VARCHAR(50) NOT NULL DEFAULT 'success',
ip_address VARCHAR(45),
user_agent TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE compliance_evidence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
check_id UUID REFERENCES compliance_checks(id) ON DELETE CASCADE,
issue_id UUID REFERENCES compliance_issues(id) ON DELETE CASCADE,
evidence_type VARCHAR(100) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
file_url TEXT,
file_name VARCHAR(255),
file_size INTEGER,
mime_type VARCHAR(100),
metadata JSONB NOT NULL DEFAULT '{}',
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
collected_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE compliance_risk_assessments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
title VARCHAR(500) NOT NULL,
assessor_id UUID NOT NULL,
methodology VARCHAR(100) NOT NULL DEFAULT 'qualitative',
overall_risk_score NUMERIC(5,2) NOT NULL DEFAULT 0,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
next_review_date DATE,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE compliance_risks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
assessment_id UUID NOT NULL REFERENCES compliance_risk_assessments(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
category VARCHAR(100) NOT NULL DEFAULT 'operational',
likelihood_score INTEGER NOT NULL DEFAULT 1 CHECK (likelihood_score BETWEEN 1 AND 5),
impact_score INTEGER NOT NULL DEFAULT 1 CHECK (impact_score BETWEEN 1 AND 5),
risk_score INTEGER NOT NULL DEFAULT 1,
risk_level VARCHAR(50) NOT NULL DEFAULT 'low',
current_controls JSONB NOT NULL DEFAULT '[]',
treatment_strategy VARCHAR(50) NOT NULL DEFAULT 'mitigate',
status VARCHAR(50) NOT NULL DEFAULT 'open',
owner_id UUID,
due_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE compliance_training_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
user_id UUID NOT NULL,
training_type VARCHAR(100) NOT NULL,
training_name VARCHAR(500) NOT NULL,
provider VARCHAR(255),
score INTEGER,
passed BOOLEAN NOT NULL DEFAULT FALSE,
completion_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_until TIMESTAMPTZ,
certificate_url TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE compliance_access_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
bot_id UUID NOT NULL,
user_id UUID NOT NULL,
reviewer_id UUID NOT NULL,
review_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
permissions_reviewed JSONB NOT NULL DEFAULT '[]',
anomalies JSONB NOT NULL DEFAULT '[]',
recommendations JSONB NOT NULL DEFAULT '[]',
status VARCHAR(50) NOT NULL DEFAULT 'pending',
approved_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_compliance_checks_org_bot ON compliance_checks(org_id, bot_id);
CREATE INDEX idx_compliance_checks_framework ON compliance_checks(framework);
CREATE INDEX idx_compliance_checks_status ON compliance_checks(status);
CREATE INDEX idx_compliance_checks_checked_at ON compliance_checks(checked_at DESC);
CREATE INDEX idx_compliance_issues_org_bot ON compliance_issues(org_id, bot_id);
CREATE INDEX idx_compliance_issues_check ON compliance_issues(check_id);
CREATE INDEX idx_compliance_issues_severity ON compliance_issues(severity);
CREATE INDEX idx_compliance_issues_status ON compliance_issues(status);
CREATE INDEX idx_compliance_issues_assigned ON compliance_issues(assigned_to) WHERE assigned_to IS NOT NULL;
CREATE INDEX idx_compliance_issues_due ON compliance_issues(due_date) WHERE due_date IS NOT NULL;
CREATE INDEX idx_compliance_audit_log_org_bot ON compliance_audit_log(org_id, bot_id);
CREATE INDEX idx_compliance_audit_log_event ON compliance_audit_log(event_type);
CREATE INDEX idx_compliance_audit_log_user ON compliance_audit_log(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_compliance_audit_log_resource ON compliance_audit_log(resource_type, resource_id);
CREATE INDEX idx_compliance_audit_log_created ON compliance_audit_log(created_at DESC);
CREATE INDEX idx_compliance_evidence_org_bot ON compliance_evidence(org_id, bot_id);
CREATE INDEX idx_compliance_evidence_check ON compliance_evidence(check_id) WHERE check_id IS NOT NULL;
CREATE INDEX idx_compliance_evidence_issue ON compliance_evidence(issue_id) WHERE issue_id IS NOT NULL;
CREATE INDEX idx_compliance_evidence_type ON compliance_evidence(evidence_type);
CREATE INDEX idx_compliance_risk_assessments_org_bot ON compliance_risk_assessments(org_id, bot_id);
CREATE INDEX idx_compliance_risk_assessments_status ON compliance_risk_assessments(status);
CREATE INDEX idx_compliance_risk_assessments_assessor ON compliance_risk_assessments(assessor_id);
CREATE INDEX idx_compliance_risks_assessment ON compliance_risks(assessment_id);
CREATE INDEX idx_compliance_risks_category ON compliance_risks(category);
CREATE INDEX idx_compliance_risks_level ON compliance_risks(risk_level);
CREATE INDEX idx_compliance_risks_status ON compliance_risks(status);
CREATE INDEX idx_compliance_training_org_bot ON compliance_training_records(org_id, bot_id);
CREATE INDEX idx_compliance_training_user ON compliance_training_records(user_id);
CREATE INDEX idx_compliance_training_type ON compliance_training_records(training_type);
CREATE INDEX idx_compliance_training_valid ON compliance_training_records(valid_until) WHERE valid_until IS NOT NULL;
CREATE INDEX idx_compliance_access_reviews_org_bot ON compliance_access_reviews(org_id, bot_id);
CREATE INDEX idx_compliance_access_reviews_user ON compliance_access_reviews(user_id);
CREATE INDEX idx_compliance_access_reviews_reviewer ON compliance_access_reviews(reviewer_id);
CREATE INDEX idx_compliance_access_reviews_status ON compliance_access_reviews(status);

File diff suppressed because it is too large Load diff

411
src/analytics/goals_ui.rs Normal file
View file

@ -0,0 +1,411 @@
use axum::{
extract::{Query, State},
response::Html,
routing::get,
Router,
};
use bigdecimal::ToPrimitive;
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{okr_checkins, okr_objectives};
use crate::shared::state::AppState;
#[derive(Debug, Deserialize, Default)]
pub struct ObjectivesQuery {
pub status: Option<String>,
pub period: Option<String>,
pub owner_id: Option<Uuid>,
pub limit: Option<i64>,
}
pub async fn objectives_list(
State(state): State<Arc<AppState>>,
Query(query): Query<ObjectivesQuery>,
) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
let mut db_query = okr_objectives::table
.filter(okr_objectives::bot_id.eq(bot_id))
.into_boxed();
if let Some(status) = query.status {
db_query = db_query.filter(okr_objectives::status.eq(status));
}
if let Some(period) = query.period {
db_query = db_query.filter(okr_objectives::period.eq(period));
}
if let Some(owner_id) = query.owner_id {
db_query = db_query.filter(okr_objectives::owner_id.eq(owner_id));
}
db_query = db_query.order(okr_objectives::created_at.desc());
if let Some(limit) = query.limit {
db_query = db_query.limit(limit);
} else {
db_query = db_query.limit(50);
}
db_query
.select((
okr_objectives::id,
okr_objectives::title,
okr_objectives::description,
okr_objectives::period,
okr_objectives::status,
okr_objectives::progress,
okr_objectives::visibility,
okr_objectives::created_at,
))
.load::<(Uuid, String, Option<String>, String, String, bigdecimal::BigDecimal, String, DateTime<Utc>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(objectives) if !objectives.is_empty() => {
let items: String = objectives
.iter()
.map(|(id, title, _desc, period, status, progress, visibility, _created)| {
let progress_val = progress.to_f32().unwrap_or(0.0);
let progress_pct = (progress_val * 100.0) as i32;
let status_class = match status.as_str() {
"active" => "status-active",
"on_track" => "status-on-track",
"at_risk" => "status-at-risk",
"behind" => "status-behind",
"completed" => "status-completed",
_ => "status-draft",
};
let progress_class = if progress_val >= 0.7 {
"progress-good"
} else if progress_val >= 0.4 {
"progress-medium"
} else {
"progress-low"
};
format!(
r##"<div class="objective-card" data-id="{id}" hx-get="/api/ui/goals/objectives/{id}" hx-target="#objective-detail" hx-swap="innerHTML">
<div class="objective-header">
<h4 class="objective-title">{title}</h4>
<span class="objective-status {status_class}"><span class="status-dot"></span>{status}</span>
</div>
<div class="objective-meta">
<span class="objective-period">{period}</span>
<span class="objective-visibility">{visibility}</span>
</div>
<div class="objective-progress">
<div class="progress-bar {progress_class}">
<div class="progress-fill" style="width: {progress_pct}%;"></div>
</div>
<span class="progress-text">{progress_pct}%</span>
</div>
</div>"##
)
})
.collect();
Html(format!(r##"<div class="objectives-list">{items}</div>"##))
}
_ => Html(
r##"<div class="empty-state">
<p>No objectives found</p>
<button class="btn btn-primary" hx-get="/api/ui/goals/new-objective" hx-target="#modal-content">
Create Objective
</button>
</div>"##.to_string(),
),
}
}
pub async fn objectives_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
okr_objectives::table
.filter(okr_objectives::bot_id.eq(bot_id))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
pub async fn active_objectives_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
okr_objectives::table
.filter(okr_objectives::bot_id.eq(bot_id))
.filter(okr_objectives::status.eq("active"))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
pub async fn at_risk_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
okr_objectives::table
.filter(okr_objectives::bot_id.eq(bot_id))
.filter(okr_objectives::status.eq("at_risk"))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
pub async fn average_progress(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
let objectives = okr_objectives::table
.filter(okr_objectives::bot_id.eq(bot_id))
.filter(okr_objectives::status.ne("draft"))
.filter(okr_objectives::status.ne("cancelled"))
.select(okr_objectives::progress)
.load::<bigdecimal::BigDecimal>(&mut conn)
.ok()?;
if objectives.is_empty() {
return Some(0.0f32);
}
let sum: f32 = objectives.iter().map(|p| p.to_f32().unwrap_or(0.0)).sum();
Some(sum / objectives.len() as f32)
})
.await
.ok()
.flatten();
let avg = result.unwrap_or(0.0);
let pct = (avg * 100.0) as i32;
Html(format!("{pct}%"))
}
pub async fn dashboard_stats(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
let total: i64 = okr_objectives::table
.filter(okr_objectives::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let active: i64 = okr_objectives::table
.filter(okr_objectives::bot_id.eq(bot_id))
.filter(okr_objectives::status.eq("active"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let at_risk: i64 = okr_objectives::table
.filter(okr_objectives::bot_id.eq(bot_id))
.filter(okr_objectives::status.eq("at_risk"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let completed: i64 = okr_objectives::table
.filter(okr_objectives::bot_id.eq(bot_id))
.filter(okr_objectives::status.eq("completed"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Some((total, active, at_risk, completed))
})
.await
.ok()
.flatten();
match result {
Some((total, active, at_risk, completed)) => Html(format!(
r##"<div class="dashboard-stats">
<div class="stat-card">
<span class="stat-value">{total}</span>
<span class="stat-label">Total Objectives</span>
</div>
<div class="stat-card stat-success">
<span class="stat-value">{active}</span>
<span class="stat-label">Active</span>
</div>
<div class="stat-card stat-warning">
<span class="stat-value">{at_risk}</span>
<span class="stat-label">At Risk</span>
</div>
<div class="stat-card stat-info">
<span class="stat-value">{completed}</span>
<span class="stat-label">Completed</span>
</div>
</div>"##
)),
None => Html(r##"<div class="dashboard-stats"><div class="stat-card"><span class="stat-value">-</span></div></div>"##.to_string()),
}
}
pub async fn new_objective_form() -> Html<String> {
Html(r##"<div class="modal-header">
<h3>New Objective</h3>
<button class="btn-close" onclick="closeModal()">&times;</button>
</div>
<form class="objective-form" hx-post="/api/goals/objectives" hx-swap="none">
<div class="form-group">
<label>Title</label>
<input type="text" name="title" placeholder="What do you want to achieve?" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" rows="3" placeholder="Describe the objective in detail"></textarea>
</div>
<div class="form-group">
<label>Period</label>
<select name="period" required>
<option value="Q1">Q1</option>
<option value="Q2">Q2</option>
<option value="Q3">Q3</option>
<option value="Q4">Q4</option>
<option value="H1">H1 (Half Year)</option>
<option value="H2">H2 (Half Year)</option>
<option value="annual">Annual</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Start Date</label>
<input type="date" name="period_start" />
</div>
<div class="form-group">
<label>End Date</label>
<input type="date" name="period_end" />
</div>
</div>
<div class="form-group">
<label>Visibility</label>
<select name="visibility">
<option value="team">Team</option>
<option value="organization">Organization</option>
<option value="private">Private</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Objective</button>
</div>
</form>"##.to_string())
}
pub async fn recent_checkins(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
okr_checkins::table
.filter(okr_checkins::bot_id.eq(bot_id))
.order(okr_checkins::created_at.desc())
.limit(10)
.select((
okr_checkins::id,
okr_checkins::new_value,
okr_checkins::note,
okr_checkins::confidence,
okr_checkins::created_at,
))
.load::<(Uuid, bigdecimal::BigDecimal, Option<String>, Option<String>, DateTime<Utc>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(checkins) if !checkins.is_empty() => {
let items: String = checkins
.iter()
.map(|(id, value, note, confidence, created)| {
let val = value.to_f64().unwrap_or(0.0);
let note_text = note.clone().unwrap_or_else(|| "No note".to_string());
let conf = confidence.clone().unwrap_or_else(|| "medium".to_string());
let conf_class = match conf.as_str() {
"high" => "confidence-high",
"low" => "confidence-low",
_ => "confidence-medium",
};
let time_str = created.format("%b %d, %H:%M").to_string();
format!(
r##"<div class="checkin-item" data-id="{id}">
<div class="checkin-header">
<span class="checkin-value">{val:.2}</span>
<span class="checkin-confidence {conf_class}">{conf}</span>
</div>
<p class="checkin-note">{note_text}</p>
<span class="checkin-time">{time_str}</span>
</div>"##
)
})
.collect();
Html(format!(r##"<div class="checkins-list">{items}</div>"##))
}
_ => Html(r##"<div class="empty-state"><p>No recent check-ins</p></div>"##.to_string()),
}
}
pub fn configure_goals_ui_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/ui/goals/objectives", get(objectives_list))
.route("/api/ui/goals/objectives/count", get(objectives_count))
.route("/api/ui/goals/objectives/active", get(active_objectives_count))
.route("/api/ui/goals/objectives/at-risk", get(at_risk_count))
.route("/api/ui/goals/dashboard", get(dashboard_stats))
.route("/api/ui/goals/progress", get(average_progress))
.route("/api/ui/goals/checkins/recent", get(recent_checkins))
.route("/api/ui/goals/new-objective", get(new_objective_form))
}

View file

@ -1,4 +1,5 @@
pub mod goals;
pub mod goals_ui;
pub mod insights;
use crate::core::urls::ApiUrls;

1077
src/attendant/mod.rs Normal file

File diff suppressed because it is too large Load diff

623
src/attendant/ui.rs Normal file
View file

@ -0,0 +1,623 @@
use axum::{
extract::{Path, Query, State},
response::Html,
routing::get,
Router,
};
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{
attendant_agent_status, attendant_queues, attendant_sessions,
};
use crate::shared::state::AppState;
#[derive(Debug, Deserialize, Default)]
pub struct SessionListQuery {
pub status: Option<String>,
pub queue_id: Option<Uuid>,
pub agent_id: Option<Uuid>,
pub limit: Option<i64>,
}
pub async fn sessions_table(
State(state): State<Arc<AppState>>,
Query(query): Query<SessionListQuery>,
) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
let mut db_query = attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.into_boxed();
if let Some(status) = query.status {
db_query = db_query.filter(attendant_sessions::status.eq(status));
}
if let Some(queue_id) = query.queue_id {
db_query = db_query.filter(attendant_sessions::queue_id.eq(queue_id));
}
if let Some(agent_id) = query.agent_id {
db_query = db_query.filter(attendant_sessions::agent_id.eq(agent_id));
}
db_query = db_query.order(attendant_sessions::created_at.desc());
if let Some(limit) = query.limit {
db_query = db_query.limit(limit);
} else {
db_query = db_query.limit(50);
}
db_query
.select((
attendant_sessions::id,
attendant_sessions::session_number,
attendant_sessions::customer_name,
attendant_sessions::customer_email,
attendant_sessions::channel,
attendant_sessions::status,
attendant_sessions::priority,
attendant_sessions::subject,
attendant_sessions::created_at,
))
.load::<(Uuid, String, Option<String>, Option<String>, String, String, i32, Option<String>, DateTime<Utc>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(sessions) if !sessions.is_empty() => {
let rows: String = sessions
.iter()
.map(|(id, number, name, email, channel, status, priority, subject, created)| {
let customer = name.clone().unwrap_or_else(|| email.clone().unwrap_or_else(|| "Unknown".to_string()));
let subj = subject.clone().unwrap_or_else(|| "No subject".to_string());
let status_class = match status.as_str() {
"waiting" => "status-waiting",
"active" => "status-active",
"ended" => "status-ended",
_ => "status-default",
};
let priority_badge = match priority {
p if *p >= 2 => r##"<span class="badge badge-high">High</span>"##,
p if *p == 1 => r##"<span class="badge badge-medium">Medium</span>"##,
_ => r##"<span class="badge badge-low">Low</span>"##,
};
let time = created.format("%Y-%m-%d %H:%M").to_string();
format!(
r##"<tr class="session-row" data-id="{}" hx-get="/api/ui/attendant/sessions/{}" hx-target="#session-detail" hx-swap="innerHTML">
<td class="session-number">{}</td>
<td class="session-customer">{}</td>
<td class="session-channel"><span class="channel-badge channel-{}">{}</span></td>
<td class="session-subject">{}</td>
<td class="session-priority">{}</td>
<td class="session-status"><span class="{}">{}</span></td>
<td class="session-time">{}</td>
</tr>"##,
id, id, number, customer, channel, channel, subj, priority_badge, status_class, status, time
)
})
.collect();
Html(format!(
r##"<table class="sessions-table">
<thead>
<tr>
<th>Session #</th>
<th>Customer</th>
<th>Channel</th>
<th>Subject</th>
<th>Priority</th>
<th>Status</th>
<th>Created</th>
</tr>
</thead>
<tbody>{}</tbody>
</table>"##,
rows
))
}
_ => Html(
r##"<div class="empty-state">
<p>No sessions found</p>
</div>"##
.to_string(),
),
}
}
pub async fn sessions_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
pub async fn waiting_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.filter(attendant_sessions::status.eq("waiting"))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
pub async fn active_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.filter(attendant_sessions::status.eq("active"))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
pub async fn agents_online_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
attendant_agent_status::table
.filter(attendant_agent_status::bot_id.eq(bot_id))
.filter(attendant_agent_status::status.eq("online"))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
pub async fn session_detail(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
attendant_sessions::table
.find(id)
.select((
attendant_sessions::id,
attendant_sessions::session_number,
attendant_sessions::customer_name,
attendant_sessions::customer_email,
attendant_sessions::customer_phone,
attendant_sessions::channel,
attendant_sessions::status,
attendant_sessions::priority,
attendant_sessions::subject,
attendant_sessions::initial_message,
attendant_sessions::notes,
attendant_sessions::created_at,
attendant_sessions::assigned_at,
attendant_sessions::ended_at,
))
.first::<(
Uuid,
String,
Option<String>,
Option<String>,
Option<String>,
String,
String,
i32,
Option<String>,
Option<String>,
Option<String>,
DateTime<Utc>,
Option<DateTime<Utc>>,
Option<DateTime<Utc>>,
)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some((id, number, name, email, phone, channel, status, priority, subject, message, notes, created, assigned, ended)) => {
let customer_name = name.unwrap_or_else(|| "Unknown".to_string());
let customer_email = email.unwrap_or_else(|| "-".to_string());
let customer_phone = phone.unwrap_or_else(|| "-".to_string());
let subject_text = subject.unwrap_or_else(|| "No subject".to_string());
let message_text = message.unwrap_or_else(|| "-".to_string());
let notes_text = notes.unwrap_or_else(|| "-".to_string());
let created_time = created.format("%Y-%m-%d %H:%M:%S").to_string();
let assigned_time = assigned.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()).unwrap_or_else(|| "-".to_string());
let ended_time = ended.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()).unwrap_or_else(|| "-".to_string());
let priority_text = match priority {
p if p >= 2 => "High",
p if p == 1 => "Medium",
_ => "Low",
};
Html(format!(
r##"<div class="session-detail-card">
<div class="detail-header">
<h3>Session #{}</h3>
<span class="status-badge status-{}">{}</span>
</div>
<div class="detail-section">
<h4>Customer Information</h4>
<div class="detail-grid">
<div class="detail-item">
<label>Name</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Email</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Phone</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Channel</label>
<span class="channel-badge channel-{}">{}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Session Details</h4>
<div class="detail-grid">
<div class="detail-item">
<label>Subject</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Priority</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Created</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Assigned</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Ended</label>
<span>{}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Initial Message</h4>
<p class="message-content">{}</p>
</div>
<div class="detail-section">
<h4>Notes</h4>
<p class="notes-content">{}</p>
</div>
<div class="detail-actions">
<button class="btn btn-primary" hx-put="/api/attendant/sessions/{}/assign" hx-swap="none">Assign to Me</button>
<button class="btn btn-secondary" hx-get="/api/ui/attendant/sessions/{}/messages" hx-target="#messages-panel">View Messages</button>
<button class="btn btn-danger" hx-put="/api/attendant/sessions/{}/end" hx-swap="none">End Session</button>
</div>
</div>"##,
number, status, status,
customer_name, customer_email, customer_phone,
channel, channel,
subject_text, priority_text,
created_time, assigned_time, ended_time,
message_text, notes_text,
id, id, id
))
}
None => Html(
r##"<div class="empty-state">
<p>Session not found</p>
</div>"##
.to_string(),
),
}
}
pub async fn queues_list(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
attendant_queues::table
.filter(attendant_queues::bot_id.eq(bot_id))
.filter(attendant_queues::is_active.eq(true))
.order(attendant_queues::priority.desc())
.select((
attendant_queues::id,
attendant_queues::name,
attendant_queues::description,
attendant_queues::priority,
))
.load::<(Uuid, String, Option<String>, i32)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(queues) if !queues.is_empty() => {
let items: String = queues
.iter()
.map(|(id, name, desc, priority)| {
let description = desc.clone().unwrap_or_default();
format!(
r##"<div class="queue-item" data-id="{}">
<div class="queue-header">
<span class="queue-name">{}</span>
<span class="queue-priority">Priority: {}</span>
</div>
<p class="queue-description">{}</p>
<div class="queue-stats" hx-get="/api/ui/attendant/queues/{}/stats" hx-trigger="load" hx-swap="innerHTML"></div>
</div>"##,
id, name, priority, description, id
)
})
.collect();
Html(format!(r##"<div class="queues-list">{}</div>"##, items))
}
_ => Html(
r##"<div class="empty-state">
<p>No queues configured</p>
</div>"##
.to_string(),
),
}
}
pub async fn queue_stats(
State(state): State<Arc<AppState>>,
Path(queue_id): Path<Uuid>,
) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let waiting: i64 = attendant_sessions::table
.filter(attendant_sessions::queue_id.eq(queue_id))
.filter(attendant_sessions::status.eq("waiting"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let active: i64 = attendant_sessions::table
.filter(attendant_sessions::queue_id.eq(queue_id))
.filter(attendant_sessions::status.eq("active"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Some((waiting, active))
})
.await
.ok()
.flatten();
match result {
Some((waiting, active)) => Html(format!(
r##"<span class="stat">Waiting: {}</span>
<span class="stat">Active: {}</span>"##,
waiting, active
)),
None => Html(r##"<span class="stat">-</span>"##.to_string()),
}
}
pub async fn agent_status_list(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
attendant_agent_status::table
.filter(attendant_agent_status::bot_id.eq(bot_id))
.order(attendant_agent_status::status.asc())
.select((
attendant_agent_status::id,
attendant_agent_status::agent_id,
attendant_agent_status::status,
attendant_agent_status::current_sessions,
attendant_agent_status::max_sessions,
attendant_agent_status::last_activity_at,
))
.load::<(Uuid, Uuid, String, i32, i32, DateTime<Utc>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(agents) if !agents.is_empty() => {
let items: String = agents
.iter()
.map(|(id, agent_id, status, current, max, last_activity)| {
let status_class = match status.as_str() {
"online" => "status-online",
"busy" => "status-busy",
"break" => "status-break",
_ => "status-offline",
};
let last_time = last_activity.format("%H:%M").to_string();
format!(
r##"<div class="agent-item" data-id="{}">
<div class="agent-avatar">
<span class="status-indicator {}"></span>
</div>
<div class="agent-info">
<span class="agent-name">Agent {}</span>
<span class="agent-status">{}</span>
</div>
<div class="agent-load">
<span>{}/{} sessions</span>
<span class="last-activity">Last: {}</span>
</div>
</div>"##,
id, status_class, &agent_id.to_string()[..8], status, current, max, last_time
)
})
.collect();
Html(format!(r##"<div class="agents-list">{}</div>"##, items))
}
_ => Html(
r##"<div class="empty-state">
<p>No agents found</p>
</div>"##
.to_string(),
),
}
}
pub async fn dashboard_stats(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
let today = Utc::now().date_naive();
let today_start = today.and_hms_opt(0, 0, 0)?;
let total_today: i64 = attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.filter(attendant_sessions::created_at.ge(today_start))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let waiting: i64 = attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.filter(attendant_sessions::status.eq("waiting"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let active: i64 = attendant_sessions::table
.filter(attendant_sessions::bot_id.eq(bot_id))
.filter(attendant_sessions::status.eq("active"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let agents_online: i64 = attendant_agent_status::table
.filter(attendant_agent_status::bot_id.eq(bot_id))
.filter(attendant_agent_status::status.eq("online"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Some((total_today, waiting, active, agents_online))
})
.await
.ok()
.flatten();
match result {
Some((total, waiting, active, agents)) => Html(format!(
r##"<div class="dashboard-stats">
<div class="stat-card">
<span class="stat-value">{}</span>
<span class="stat-label">Sessions Today</span>
</div>
<div class="stat-card stat-warning">
<span class="stat-value">{}</span>
<span class="stat-label">Waiting</span>
</div>
<div class="stat-card stat-success">
<span class="stat-value">{}</span>
<span class="stat-label">Active</span>
</div>
<div class="stat-card stat-info">
<span class="stat-value">{}</span>
<span class="stat-label">Agents Online</span>
</div>
</div>"##,
total, waiting, active, agents
)),
None => Html(
r##"<div class="dashboard-stats">
<div class="stat-card"><span class="stat-value">-</span></div>
</div>"##
.to_string(),
),
}
}
pub fn configure_attendant_ui_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/ui/attendant/sessions", get(sessions_table))
.route("/api/ui/attendant/sessions/count", get(sessions_count))
.route("/api/ui/attendant/sessions/waiting", get(waiting_count))
.route("/api/ui/attendant/sessions/active", get(active_count))
.route("/api/ui/attendant/sessions/:id", get(session_detail))
.route("/api/ui/attendant/queues", get(queues_list))
.route("/api/ui/attendant/queues/:id/stats", get(queue_stats))
.route("/api/ui/attendant/agents", get(agent_status_list))
.route("/api/ui/attendant/agents/online", get(agents_online_count))
.route("/api/ui/attendant/dashboard", get(dashboard_stats))
}

1110
src/billing/api.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -4,14 +4,25 @@ use axum::{
routing::get,
Router,
};
use bigdecimal::{BigDecimal, ToPrimitive};
use chrono::{DateTime, Datelike, NaiveDate, Utc};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{billing_invoices, billing_payments, billing_quotes};
use crate::shared::state::AppState;
fn bd_to_f64(bd: &BigDecimal) -> f64 {
bd.to_f64().unwrap_or(0.0)
}
#[derive(Debug, Deserialize)]
pub struct StatusQuery {
pub status: Option<String>,
pub limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
@ -19,6 +30,23 @@ pub struct SearchQuery {
pub q: Option<String>,
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn format_currency(amount: f64, currency: &str) -> String {
match currency.to_uppercase().as_str() {
"USD" => format!("${:.2}", amount),
"EUR" => format!("{:.2}", amount),
"GBP" => format!("£{:.2}", amount),
"BRL" => format!("R${:.2}", amount),
_ => format!("{:.2} {}", amount, currency),
}
}
pub fn configure_billing_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/billing/invoices", get(handle_invoices))
@ -32,81 +60,460 @@ pub fn configure_billing_routes() -> Router<Arc<AppState>> {
}
async fn handle_invoices(
State(_state): State<Arc<AppState>>,
Query(_query): Query<StatusQuery>,
State(state): State<Arc<AppState>>,
Query(query): Query<StatusQuery>,
) -> impl IntoResponse {
Html(
r#"<tr class="empty-row">
<td colspan="7" class="empty-state">
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let mut db_query = billing_invoices::table
.filter(billing_invoices::bot_id.eq(bot_id))
.into_boxed();
if let Some(ref status) = query.status {
db_query = db_query.filter(billing_invoices::status.eq(status));
}
db_query = db_query.order(billing_invoices::created_at.desc());
let limit = query.limit.unwrap_or(50);
db_query = db_query.limit(limit);
db_query
.select((
billing_invoices::id,
billing_invoices::invoice_number,
billing_invoices::customer_name,
billing_invoices::customer_email,
billing_invoices::status,
billing_invoices::issue_date,
billing_invoices::due_date,
billing_invoices::total,
billing_invoices::amount_due,
billing_invoices::currency,
))
.load::<(Uuid, String, String, Option<String>, String, NaiveDate, NaiveDate, BigDecimal, BigDecimal, String)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(invoices) if !invoices.is_empty() => {
let mut html = String::new();
for (id, number, customer_name, customer_email, status, issue_date, due_date, total, amount_due, currency) in invoices {
let name = customer_email.unwrap_or_else(|| customer_name.clone());
let total_str = format_currency(bd_to_f64(&total), &currency);
let due_str = format_currency(bd_to_f64(&amount_due), &currency);
let issue_str = issue_date.format("%Y-%m-%d").to_string();
let due_date_str = due_date.format("%Y-%m-%d").to_string();
let status_class = match status.as_str() {
"paid" => "status-paid",
"sent" => "status-sent",
"overdue" => "status-overdue",
"void" => "status-void",
_ => "status-draft",
};
html.push_str(&format!(
r##"<tr class="invoice-row" data-id="{id}">
<td class="invoice-number">{}</td>
<td class="invoice-customer">{}</td>
<td class="invoice-date">{}</td>
<td class="invoice-due">{}</td>
<td class="invoice-total">{}</td>
<td class="invoice-balance">{}</td>
<td class="invoice-status"><span class="{}">{}</span></td>
<td class="invoice-actions">
<button class="btn-sm" hx-get="/api/billing/invoices/{id}" hx-target="#invoice-detail">View</button>
</td>
</tr>"##,
html_escape(&number),
html_escape(&name),
issue_str,
due_date_str,
total_str,
due_str,
status_class,
html_escape(&status)
));
}
Html(html)
}
_ => Html(
r##"<tr class="empty-row">
<td colspan="8" class="empty-state">
<div class="empty-icon">📄</div>
<p>No invoices yet</p>
<p class="empty-hint">Create your first invoice to get started</p>
</td>
</tr>"#
.to_string(),
)
</tr>"##.to_string(),
),
}
}
async fn handle_payments(
State(_state): State<Arc<AppState>>,
Query(_query): Query<StatusQuery>,
State(state): State<Arc<AppState>>,
Query(query): Query<StatusQuery>,
) -> impl IntoResponse {
Html(
r#"<tr class="empty-row">
<td colspan="6" class="empty-state">
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let mut db_query = billing_payments::table
.filter(billing_payments::bot_id.eq(bot_id))
.into_boxed();
if let Some(ref status) = query.status {
db_query = db_query.filter(billing_payments::status.eq(status));
}
db_query = db_query.order(billing_payments::created_at.desc());
let limit = query.limit.unwrap_or(50);
db_query = db_query.limit(limit);
db_query
.select((
billing_payments::id,
billing_payments::payment_number,
billing_payments::amount,
billing_payments::currency,
billing_payments::payment_method,
billing_payments::payer_name,
billing_payments::payer_email,
billing_payments::status,
billing_payments::paid_at,
))
.load::<(Uuid, String, BigDecimal, String, String, Option<String>, Option<String>, String, DateTime<Utc>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(payments) if !payments.is_empty() => {
let mut html = String::new();
for (id, number, amount, currency, method, payer_name, payer_email, status, paid_at) in payments {
let amount_str = format_currency(bd_to_f64(&amount), &currency);
let payer = payer_name.unwrap_or_else(|| payer_email.unwrap_or_else(|| "Unknown".to_string()));
let date_str = paid_at.format("%Y-%m-%d %H:%M").to_string();
let status_class = match status.as_str() {
"completed" => "status-completed",
"pending" => "status-pending",
"refunded" => "status-refunded",
"failed" => "status-failed",
_ => "status-default",
};
html.push_str(&format!(
r##"<tr class="payment-row" data-id="{id}">
<td class="payment-number">{}</td>
<td class="payment-payer">{}</td>
<td class="payment-amount">{}</td>
<td class="payment-method">{}</td>
<td class="payment-date">{}</td>
<td class="payment-status"><span class="{}">{}</span></td>
<td class="payment-actions">
<button class="btn-sm" hx-get="/api/billing/payments/{id}" hx-target="#payment-detail">View</button>
</td>
</tr>"##,
html_escape(&number),
html_escape(&payer),
amount_str,
html_escape(&method),
date_str,
status_class,
html_escape(&status)
));
}
Html(html)
}
_ => Html(
r##"<tr class="empty-row">
<td colspan="7" class="empty-state">
<div class="empty-icon">💳</div>
<p>No payments recorded</p>
<p class="empty-hint">Payments will appear here when invoices are paid</p>
</td>
</tr>"#
.to_string(),
)
</tr>"##.to_string(),
),
}
}
async fn handle_quotes(
State(_state): State<Arc<AppState>>,
Query(_query): Query<StatusQuery>,
State(state): State<Arc<AppState>>,
Query(query): Query<StatusQuery>,
) -> impl IntoResponse {
Html(
r#"<tr class="empty-row">
<td colspan="6" class="empty-state">
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let mut db_query = billing_quotes::table
.filter(billing_quotes::bot_id.eq(bot_id))
.into_boxed();
if let Some(ref status) = query.status {
db_query = db_query.filter(billing_quotes::status.eq(status));
}
db_query = db_query.order(billing_quotes::created_at.desc());
let limit = query.limit.unwrap_or(50);
db_query = db_query.limit(limit);
db_query
.select((
billing_quotes::id,
billing_quotes::quote_number,
billing_quotes::customer_name,
billing_quotes::customer_email,
billing_quotes::status,
billing_quotes::issue_date,
billing_quotes::valid_until,
billing_quotes::total,
billing_quotes::currency,
))
.load::<(Uuid, String, String, Option<String>, String, NaiveDate, NaiveDate, BigDecimal, String)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(quotes) if !quotes.is_empty() => {
let mut html = String::new();
for (id, number, customer_name, customer_email, status, issue_date, valid_until, total, currency) in quotes {
let name = customer_email.unwrap_or_else(|| customer_name.clone());
let total_str = format_currency(bd_to_f64(&total), &currency);
let issue_str = issue_date.format("%Y-%m-%d").to_string();
let valid_str = valid_until.format("%Y-%m-%d").to_string();
let status_class = match status.as_str() {
"accepted" => "status-accepted",
"sent" => "status-sent",
"rejected" => "status-rejected",
"expired" => "status-expired",
"converted" => "status-converted",
_ => "status-draft",
};
html.push_str(&format!(
r##"<tr class="quote-row" data-id="{id}">
<td class="quote-number">{}</td>
<td class="quote-customer">{}</td>
<td class="quote-date">{}</td>
<td class="quote-valid">{}</td>
<td class="quote-total">{}</td>
<td class="quote-status"><span class="{}">{}</span></td>
<td class="quote-actions">
<button class="btn-sm" hx-get="/api/billing/quotes/{id}" hx-target="#quote-detail">View</button>
</td>
</tr>"##,
html_escape(&number),
html_escape(&name),
issue_str,
valid_str,
total_str,
status_class,
html_escape(&status)
));
}
Html(html)
}
_ => Html(
r##"<tr class="empty-row">
<td colspan="7" class="empty-state">
<div class="empty-icon">📝</div>
<p>No quotes yet</p>
<p class="empty-hint">Create quotes for your prospects</p>
</td>
</tr>"#
.to_string(),
)
</tr>"##.to_string(),
),
}
}
async fn handle_stats_pending(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("$0".to_string())
async fn handle_stats_pending(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let totals: Vec<BigDecimal> = billing_invoices::table
.filter(billing_invoices::bot_id.eq(bot_id))
.filter(billing_invoices::status.eq_any(vec!["sent", "draft"]))
.select(billing_invoices::amount_due)
.load(&mut conn)
.ok()?;
let sum: f64 = totals.iter().map(bd_to_f64).sum();
Some(sum)
})
.await
.ok()
.flatten();
Html(format_currency(result.unwrap_or(0.0), "USD"))
}
async fn handle_revenue_month(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("$0".to_string())
async fn handle_revenue_month(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let now = Utc::now();
let month_start = now.date_naive().with_day(1)?.and_hms_opt(0, 0, 0)?;
let totals: Vec<BigDecimal> = billing_invoices::table
.filter(billing_invoices::bot_id.eq(bot_id))
.filter(billing_invoices::created_at.ge(month_start))
.select(billing_invoices::total)
.load(&mut conn)
.ok()?;
let sum: f64 = totals.iter().map(bd_to_f64).sum();
Some(sum)
})
.await
.ok()
.flatten();
Html(format_currency(result.unwrap_or(0.0), "USD"))
}
async fn handle_paid_month(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("$0".to_string())
async fn handle_paid_month(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let now = Utc::now();
let month_start = now.date_naive().with_day(1)?.and_hms_opt(0, 0, 0)?;
let totals: Vec<BigDecimal> = billing_payments::table
.filter(billing_payments::bot_id.eq(bot_id))
.filter(billing_payments::status.eq("completed"))
.filter(billing_payments::created_at.ge(month_start))
.select(billing_payments::amount)
.load(&mut conn)
.ok()?;
let sum: f64 = totals.iter().map(bd_to_f64).sum();
Some(sum)
})
.await
.ok()
.flatten();
Html(format_currency(result.unwrap_or(0.0), "USD"))
}
async fn handle_overdue(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("$0".to_string())
async fn handle_overdue(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let totals: Vec<BigDecimal> = billing_invoices::table
.filter(billing_invoices::bot_id.eq(bot_id))
.filter(billing_invoices::status.eq("overdue"))
.select(billing_invoices::amount_due)
.load(&mut conn)
.ok()?;
let sum: f64 = totals.iter().map(bd_to_f64).sum();
Some(sum)
})
.await
.ok()
.flatten();
Html(format_currency(result.unwrap_or(0.0), "USD"))
}
async fn handle_billing_search(
State(_state): State<Arc<AppState>>,
State(state): State<Arc<AppState>>,
Query(query): Query<SearchQuery>,
) -> impl IntoResponse {
let q = query.q.unwrap_or_default();
let q = query.q.clone().unwrap_or_default();
if q.is_empty() {
return Html(String::new());
}
Html(format!(
r#"<div class="search-results-empty">
<p>No results for "{}"</p>
</div>"#,
q
let pool = state.conn.clone();
let search_term = format!("%{}%", q);
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
billing_invoices::table
.filter(billing_invoices::bot_id.eq(bot_id))
.filter(
billing_invoices::invoice_number.ilike(&search_term)
.or(billing_invoices::customer_name.ilike(&search_term))
.or(billing_invoices::customer_email.ilike(&search_term))
)
.order(billing_invoices::created_at.desc())
.limit(20)
.select((
billing_invoices::id,
billing_invoices::invoice_number,
billing_invoices::customer_name,
billing_invoices::status,
billing_invoices::total,
billing_invoices::currency,
))
.load::<(Uuid, String, String, String, BigDecimal, String)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(items) if !items.is_empty() => {
let mut html = String::new();
for (id, number, customer, status, total, currency) in items {
let total_str = format_currency(bd_to_f64(&total), &currency);
html.push_str(&format!(
r##"<div class="search-result-item" hx-get="/api/billing/invoices/{id}" hx-target="#invoice-detail">
<span class="result-number">{}</span>
<span class="result-customer">{}</span>
<span class="result-status">{}</span>
<span class="result-total">{}</span>
</div>"##,
html_escape(&number),
html_escape(&customer),
html_escape(&status),
total_str
));
}
Html(format!(r##"<div class="search-results">{html}</div>"##))
}
_ => Html(format!(
r##"<div class="search-results-empty">
<p>No results for "{}"</p>
</div>"##,
html_escape(&q)
)),
}
}

View file

@ -6,6 +6,7 @@ use tokio::sync::RwLock;
use uuid::Uuid;
pub mod alerts;
pub mod api;
pub mod billing_ui;
pub mod invoice;
pub mod lifecycle;

View file

@ -6,7 +6,7 @@ use axum::{
};
use std::sync::Arc;
use super::CalendarEngine;
use crate::basic::keywords::book::CalendarEngine;
use crate::shared::state::AppState;
pub fn create_caldav_router(_engine: Arc<CalendarEngine>) -> Router<Arc<AppState>> {

File diff suppressed because it is too large Load diff

780
src/calendar/ui.rs Normal file
View file

@ -0,0 +1,780 @@
use axum::{
extract::{Path, Query, State},
response::Html,
routing::get,
Router,
};
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{calendar_events, calendars};
use crate::shared::state::AppState;
#[derive(Debug, Deserialize, Default)]
pub struct EventsQuery {
pub calendar_id: Option<Uuid>,
pub start: Option<DateTime<Utc>>,
pub end: Option<DateTime<Utc>>,
pub view: Option<String>,
}
pub async fn events_list(
State(state): State<Arc<AppState>>,
Query(query): Query<EventsQuery>,
) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
let now = Utc::now();
let start = query.start.unwrap_or(now);
let end = query.end.unwrap_or(now + Duration::days(30));
let mut db_query = calendar_events::table
.filter(calendar_events::bot_id.eq(bot_id))
.filter(calendar_events::start_time.ge(start))
.filter(calendar_events::start_time.le(end))
.into_boxed();
if let Some(calendar_id) = query.calendar_id {
db_query = db_query.filter(calendar_events::calendar_id.eq(calendar_id));
}
db_query = db_query.order(calendar_events::start_time.asc());
db_query
.select((
calendar_events::id,
calendar_events::title,
calendar_events::description,
calendar_events::location,
calendar_events::start_time,
calendar_events::end_time,
calendar_events::all_day,
calendar_events::color,
calendar_events::status,
))
.load::<(
Uuid,
String,
Option<String>,
Option<String>,
DateTime<Utc>,
DateTime<Utc>,
bool,
Option<String>,
String,
)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(events) if !events.is_empty() => {
let items: String = events
.iter()
.map(|(id, title, desc, location, start, end, all_day, color, status)| {
let event_color = color.clone().unwrap_or_else(|| "#3b82f6".to_string());
let location_text = location.clone().unwrap_or_default();
let time_str = if *all_day {
"All day".to_string()
} else {
format!("{} - {}", start.format("%H:%M"), end.format("%H:%M"))
};
let date_str = start.format("%b %d").to_string();
format!(
r##"<div class="event-item" data-id="{}" style="border-left: 4px solid {};"
hx-get="/api/ui/calendar/events/{}" hx-target="#event-detail" hx-swap="innerHTML">
<div class="event-date">{}</div>
<div class="event-content">
<span class="event-title">{}</span>
<span class="event-time">{}</span>
{}</div>
</div>"##,
id,
event_color,
id,
date_str,
title,
time_str,
if location_text.is_empty() {
String::new()
} else {
format!(r##"<span class="event-location">{}</span>"##, location_text)
}
)
})
.collect();
Html(format!(r##"<div class="events-list">{}</div>"##, items))
}
_ => Html(
r##"<div class="empty-state">
<p>No events found</p>
<button class="btn btn-primary" hx-get="/api/ui/calendar/new-event" hx-target="#modal-content">
Create Event
</button>
</div>"##
.to_string(),
),
}
}
pub async fn event_detail(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
calendar_events::table
.find(id)
.select((
calendar_events::id,
calendar_events::title,
calendar_events::description,
calendar_events::location,
calendar_events::start_time,
calendar_events::end_time,
calendar_events::all_day,
calendar_events::color,
calendar_events::status,
calendar_events::attendees,
))
.first::<(
Uuid,
String,
Option<String>,
Option<String>,
DateTime<Utc>,
DateTime<Utc>,
bool,
Option<String>,
String,
serde_json::Value,
)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some((id, title, desc, location, start, end, all_day, color, status, attendees)) => {
let description = desc.unwrap_or_else(|| "No description".to_string());
let location_text = location.unwrap_or_else(|| "No location".to_string());
let event_color = color.unwrap_or_else(|| "#3b82f6".to_string());
let time_str = if all_day {
format!("{} (All day)", start.format("%B %d, %Y"))
} else {
format!(
"{} - {}",
start.format("%B %d, %Y %H:%M"),
end.format("%H:%M")
)
};
let attendees_list: Vec<String> =
serde_json::from_value(attendees).unwrap_or_default();
let attendees_html = if attendees_list.is_empty() {
"<p>No attendees</p>".to_string()
} else {
attendees_list
.iter()
.map(|a| format!(r##"<span class="attendee-badge">{}</span>"##, a))
.collect::<Vec<_>>()
.join("")
};
Html(format!(
r##"<div class="event-detail-card">
<div class="detail-header" style="border-left: 4px solid {};">
<h3>{}</h3>
<span class="status-badge status-{}">{}</span>
</div>
<div class="detail-section">
<div class="detail-item">
<label>When</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Where</label>
<span>{}</span>
</div>
</div>
<div class="detail-section">
<h4>Description</h4>
<p>{}</p>
</div>
<div class="detail-section">
<h4>Attendees</h4>
<div class="attendees-list">{}</div>
</div>
<div class="detail-actions">
<button class="btn btn-primary" hx-get="/api/ui/calendar/events/{}/edit" hx-target="#modal-content">Edit</button>
<button class="btn btn-danger" hx-delete="/api/calendar/events/{}" hx-swap="none" hx-confirm="Delete this event?">Delete</button>
</div>
</div>"##,
event_color,
title,
status,
status,
time_str,
location_text,
description,
attendees_html,
id,
id
))
}
None => Html(
r##"<div class="empty-state">
<p>Event not found</p>
</div>"##
.to_string(),
),
}
}
pub async fn calendars_sidebar(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
calendars::table
.filter(calendars::bot_id.eq(bot_id))
.order(calendars::is_primary.desc())
.select((
calendars::id,
calendars::name,
calendars::color,
calendars::is_visible,
calendars::is_primary,
))
.load::<(Uuid, String, Option<String>, bool, bool)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(cals) if !cals.is_empty() => {
let items: String = cals
.iter()
.map(|(id, name, color, visible, primary)| {
let cal_color = color.clone().unwrap_or_else(|| "#3b82f6".to_string());
let checked = if *visible { "checked" } else { "" };
let primary_badge = if *primary {
r##"<span class="primary-badge">Primary</span>"##
} else {
""
};
format!(
r##"<div class="calendar-item" data-calendar-id="{}">
<input type="checkbox" class="calendar-checkbox" {}
hx-put="/api/calendar/calendars/{}"
hx-vals='{{"is_visible": {}}}'
hx-swap="none" />
<span class="calendar-color" style="background: {};"></span>
<span class="calendar-name">{}</span>
{}
</div>"##,
id,
checked,
id,
!visible,
cal_color,
name,
primary_badge
)
})
.collect();
Html(format!(
r##"<div class="calendars-sidebar">
<div class="sidebar-header">
<h4>My Calendars</h4>
<button class="btn-icon" hx-get="/api/ui/calendar/new-calendar" hx-target="#modal-content">+</button>
</div>
<div class="calendars-list">{}</div>
</div>"##,
items
))
}
_ => Html(
r##"<div class="calendars-sidebar">
<div class="sidebar-header">
<h4>My Calendars</h4>
<button class="btn-icon" hx-get="/api/ui/calendar/new-calendar" hx-target="#modal-content">+</button>
</div>
<div class="empty-state">
<p>No calendars yet</p>
</div>
</div>"##
.to_string(),
),
}
}
pub async fn upcoming_events(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
let now = Utc::now();
let end = now + Duration::days(7);
calendar_events::table
.filter(calendar_events::bot_id.eq(bot_id))
.filter(calendar_events::start_time.ge(now))
.filter(calendar_events::start_time.le(end))
.order(calendar_events::start_time.asc())
.limit(5)
.select((
calendar_events::id,
calendar_events::title,
calendar_events::start_time,
calendar_events::color,
))
.load::<(Uuid, String, DateTime<Utc>, Option<String>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(events) if !events.is_empty() => {
let items: String = events
.iter()
.map(|(id, title, start, color)| {
let event_color = color.clone().unwrap_or_else(|| "#3b82f6".to_string());
let time_str = start.format("%b %d, %H:%M").to_string();
format!(
r##"<div class="upcoming-event" hx-get="/api/ui/calendar/events/{}" hx-target="#event-detail">
<div class="upcoming-color" style="background: {};"></div>
<div class="upcoming-info">
<span class="upcoming-title">{}</span>
<span class="upcoming-time">{}</span>
</div>
</div>"##,
id, event_color, title, time_str
)
})
.collect();
Html(format!(r##"<div class="upcoming-list">{}</div>"##, items))
}
_ => Html(
r##"<div class="upcoming-event">
<div class="upcoming-color" style="background: #94a3b8;"></div>
<div class="upcoming-info">
<span class="upcoming-title">No upcoming events</span>
<span class="upcoming-time">Create your first event</span>
</div>
</div>"##
.to_string(),
),
}
}
pub async fn events_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
calendar_events::table
.filter(calendar_events::bot_id.eq(bot_id))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
pub async fn today_events_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.pool.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
let today = Utc::now().date_naive();
let today_start = today.and_hms_opt(0, 0, 0)?;
let today_end = today.and_hms_opt(23, 59, 59)?;
calendar_events::table
.filter(calendar_events::bot_id.eq(bot_id))
.filter(calendar_events::start_time.ge(today_start))
.filter(calendar_events::start_time.le(today_end))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
#[derive(Debug, Deserialize, Default)]
pub struct MonthQuery {
pub year: Option<i32>,
pub month: Option<u32>,
}
pub async fn month_view(
State(state): State<Arc<AppState>>,
Query(query): Query<MonthQuery>,
) -> Html<String> {
let pool = state.pool.clone();
let now = Utc::now();
let year = query.year.unwrap_or(now.year());
let month = query.month.unwrap_or(now.month());
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
let first_day = NaiveDate::from_ymd_opt(year, month, 1)?;
let last_day = if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1)?
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1)?
}
.pred_opt()?;
let start = first_day.and_hms_opt(0, 0, 0)?;
let end = last_day.and_hms_opt(23, 59, 59)?;
let events = calendar_events::table
.filter(calendar_events::bot_id.eq(bot_id))
.filter(calendar_events::start_time.ge(start))
.filter(calendar_events::start_time.le(end))
.select((
calendar_events::id,
calendar_events::title,
calendar_events::start_time,
calendar_events::color,
))
.load::<(Uuid, String, DateTime<Utc>, Option<String>)>(&mut conn)
.ok()?;
Some((first_day, last_day, events))
})
.await
.ok()
.flatten();
match result {
Some((first_day, last_day, events)) => {
let month_name = first_day.format("%B %Y").to_string();
let start_weekday = first_day.weekday().num_days_from_sunday();
let mut days_html = String::new();
for _ in 0..start_weekday {
days_html.push_str(r#"<div class="calendar-day empty"></div>"#);
}
let mut current = first_day;
while current <= last_day {
let day_num = current.day();
let day_events: Vec<_> = events
.iter()
.filter(|(_, _, start, _)| start.date_naive() == current)
.collect();
let events_dots: String = day_events
.iter()
.take(3)
.map(|(_, _, _, color)| {
let c = color.clone().unwrap_or_else(|| "#3b82f6".to_string());
format!(r##"<span class="event-dot" style="background: {};"></span>"##, c)
})
.collect();
let is_today = current == Utc::now().date_naive();
let today_class = if is_today { "today" } else { "" };
days_html.push_str(&format!(
r##"<div class="calendar-day {}" data-date="{}"
hx-get="/api/ui/calendar/day?date={}" hx-target="#day-events">
<span class="day-number">{}</span>
<div class="day-events">{}</div>
</div>"##,
today_class,
current,
current,
day_num,
events_dots
));
current = current.succ_opt().unwrap_or(current);
}
let prev_month = if month == 1 { 12 } else { month - 1 };
let prev_year = if month == 1 { year - 1 } else { year };
let next_month = if month == 12 { 1 } else { month + 1 };
let next_year = if month == 12 { year + 1 } else { year };
Html(format!(
r##"<div class="calendar-month">
<div class="month-header">
<button class="btn-icon" hx-get="/api/ui/calendar/month?year={}&month={}" hx-target="#calendar-view">&lt;</button>
<h3>{}</h3>
<button class="btn-icon" hx-get="/api/ui/calendar/month?year={}&month={}" hx-target="#calendar-view">&gt;</button>
</div>
<div class="weekdays">
<div>Sun</div><div>Mon</div><div>Tue</div><div>Wed</div><div>Thu</div><div>Fri</div><div>Sat</div>
</div>
<div class="days-grid">{}</div>
</div>"##,
prev_year, prev_month,
month_name,
next_year, next_month,
days_html
))
}
None => Html(
r##"<div class="empty-state">
<p>Could not load calendar</p>
</div>"##
.to_string(),
),
}
}
#[derive(Debug, Deserialize)]
pub struct DayQuery {
pub date: NaiveDate,
}
pub async fn day_events(
State(state): State<Arc<AppState>>,
Query(query): Query<DayQuery>,
) -> Html<String> {
let pool = state.pool.clone();
let date = query.date;
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (_, bot_id) = get_default_bot(&mut conn).ok()?;
let start = date.and_hms_opt(0, 0, 0)?;
let end = date.and_hms_opt(23, 59, 59)?;
calendar_events::table
.filter(calendar_events::bot_id.eq(bot_id))
.filter(calendar_events::start_time.ge(start))
.filter(calendar_events::start_time.le(end))
.order(calendar_events::start_time.asc())
.select((
calendar_events::id,
calendar_events::title,
calendar_events::start_time,
calendar_events::end_time,
calendar_events::color,
calendar_events::all_day,
))
.load::<(Uuid, String, DateTime<Utc>, DateTime<Utc>, Option<String>, bool)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
let date_str = date.format("%A, %B %d, %Y").to_string();
match result {
Some(events) if !events.is_empty() => {
let items: String = events
.iter()
.map(|(id, title, start, end, color, all_day)| {
let event_color = color.clone().unwrap_or_else(|| "#3b82f6".to_string());
let time_str = if *all_day {
"All day".to_string()
} else {
format!("{} - {}", start.format("%H:%M"), end.format("%H:%M"))
};
format!(
r##"<div class="day-event-item" style="border-left: 4px solid {};"
hx-get="/api/ui/calendar/events/{}" hx-target="#event-detail">
<span class="event-time">{}</span>
<span class="event-title">{}</span>
</div>"##,
event_color, id, time_str, title
)
})
.collect();
Html(format!(
r##"<div class="day-events-panel">
<h4>{}</h4>
<div class="events-list">{}</div>
</div>"##,
date_str, items
))
}
_ => Html(format!(
r##"<div class="day-events-panel">
<h4>{}</h4>
<div class="empty-state">
<p>No events on this day</p>
</div>
</div>"##,
date_str
)),
}
}
pub async fn new_event_form() -> Html<String> {
let now = Utc::now();
let date = now.format("%Y-%m-%d").to_string();
let time = now.format("%H:00").to_string();
let end_time = (now + Duration::hours(1)).format("%H:00").to_string();
Html(format!(
r##"<div class="modal-header">
<h3>New Event</h3>
<button class="btn-close" onclick="closeModal()">&times;</button>
</div>
<form class="event-form" hx-post="/api/calendar/events" hx-swap="none" hx-on::after-request="closeModal(); htmx.trigger('#calendar-view', 'refresh')">
<div class="form-group">
<label>Title</label>
<input type="text" name="title" placeholder="Event title" required />
</div>
<div class="form-row">
<div class="form-group">
<label>Date</label>
<input type="date" name="date" value="{}" required />
</div>
<div class="form-group">
<label>All Day</label>
<input type="checkbox" name="all_day" onchange="toggleTimeInputs(this)" />
</div>
</div>
<div class="form-row time-inputs">
<div class="form-group">
<label>Start Time</label>
<input type="time" name="start_time" value="{}" />
</div>
<div class="form-group">
<label>End Time</label>
<input type="time" name="end_time" value="{}" />
</div>
</div>
<div class="form-group">
<label>Location</label>
<input type="text" name="location" placeholder="Add location" />
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" rows="3" placeholder="Add description"></textarea>
</div>
<div class="form-group">
<label>Color</label>
<div class="color-options">
<label><input type="radio" name="color" value="#3b82f6" checked /><span class="color-dot" style="background:#3b82f6"></span></label>
<label><input type="radio" name="color" value="#22c55e" /><span class="color-dot" style="background:#22c55e"></span></label>
<label><input type="radio" name="color" value="#f59e0b" /><span class="color-dot" style="background:#f59e0b"></span></label>
<label><input type="radio" name="color" value="#ef4444" /><span class="color-dot" style="background:#ef4444"></span></label>
<label><input type="radio" name="color" value="#8b5cf6" /><span class="color-dot" style="background:#8b5cf6"></span></label>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Event</button>
</div>
</form>"##,
date, time, end_time
))
}
pub async fn new_calendar_form() -> Html<String> {
Html(
r##"<div class="modal-header">
<h3>New Calendar</h3>
<button class="btn-close" onclick="closeModal()">&times;</button>
</div>
<form class="calendar-form" hx-post="/api/calendar/calendars" hx-swap="none" hx-on::after-request="closeModal(); htmx.trigger('#calendars-sidebar', 'refresh')">
<div class="form-group">
<label>Calendar Name</label>
<input type="text" name="name" placeholder="My Calendar" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" rows="2" placeholder="Calendar description"></textarea>
</div>
<div class="form-group">
<label>Color</label>
<div class="color-options">
<label><input type="radio" name="color" value="#3b82f6" checked /><span class="color-dot" style="background:#3b82f6"></span></label>
<label><input type="radio" name="color" value="#22c55e" /><span class="color-dot" style="background:#22c55e"></span></label>
<label><input type="radio" name="color" value="#f59e0b" /><span class="color-dot" style="background:#f59e0b"></span></label>
<label><input type="radio" name="color" value="#ef4444" /><span class="color-dot" style="background:#ef4444"></span></label>
<label><input type="radio" name="color" value="#8b5cf6" /><span class="color-dot" style="background:#8b5cf6"></span></label>
</div>
</div>
<div class="form-group">
<label>Timezone</label>
<select name="timezone">
<option value="UTC">UTC</option>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="Europe/London">London</option>
<option value="Europe/Paris">Paris</option>
<option value="Asia/Tokyo">Tokyo</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Calendar</button>
</div>
</form>"##
.to_string(),
)
}
pub fn configure_calendar_ui_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/ui/calendar/events", get(events_list))
.route("/api/ui/calendar/events/count", get(events_count))
.route("/api/ui/calendar/events/today", get(today_events_count))
.route("/api/ui/calendar/events/:id", get(event_detail))
.route("/api/ui/calendar/calendars", get(calendars_sidebar))
.route("/api/ui/calendar/upcoming", get(upcoming_events))
.route("/api/ui/calendar/month", get(month_view))
.route("/api/ui/calendar/day", get(day_events))
.route("/api/ui/calendar/new-event", get(new_event_form))
.route("/api/ui/calendar/new-calendar", get(new_calendar_form))
}

File diff suppressed because it is too large Load diff

753
src/canvas/ui.rs Normal file
View file

@ -0,0 +1,753 @@
use axum::{
extract::{Path, Query, State},
response::Html,
routing::get,
Router,
};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{canvas_elements, canvases};
use crate::shared::state::AppState;
use super::{DbCanvas, DbCanvasElement};
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub search: Option<String>,
pub is_template: Option<bool>,
}
fn get_bot_context(state: &AppState) -> (Uuid, Uuid) {
let Ok(mut conn) = state.conn.get() else {
return (Uuid::nil(), Uuid::nil());
};
let (bot_id, _bot_name) = get_default_bot(&mut conn);
let org_id = Uuid::nil();
(org_id, bot_id)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
fn render_empty_state(icon: &str, title: &str, description: &str) -> String {
format!(
r##"<div class="empty-state">
<div class="empty-icon">{icon}</div>
<h3>{title}</h3>
<p>{description}</p>
</div>"##
)
}
fn render_canvas_card(canvas: &DbCanvas, element_count: i64) -> String {
let name = html_escape(&canvas.name);
let description = canvas
.description
.as_deref()
.map(html_escape)
.unwrap_or_default();
let bg_color = canvas
.background_color
.as_deref()
.unwrap_or("#ffffff");
let updated = canvas.updated_at.format("%Y-%m-%d %H:%M").to_string();
let id = canvas.id;
let template_badge = if canvas.is_template {
r##"<span class="badge badge-info">Template</span>"##
} else {
""
};
let public_badge = if canvas.is_public {
r##"<span class="badge badge-success">Public</span>"##
} else {
""
};
format!(
r##"<div class="canvas-card" data-id="{id}">
<div class="canvas-preview" style="background-color: {bg_color};">
<div class="canvas-element-count">{element_count} elements</div>
</div>
<div class="canvas-info">
<h4 class="canvas-name">{name}</h4>
<p class="canvas-description">{description}</p>
<div class="canvas-meta">
<span class="canvas-updated">{updated}</span>
{template_badge}
{public_badge}
</div>
</div>
<div class="canvas-actions">
<button class="btn btn-sm btn-primary" hx-get="/api/ui/canvas/{id}/editor" hx-target="#canvas-editor" hx-swap="innerHTML">
Open
</button>
<button class="btn btn-sm btn-secondary" hx-get="/api/ui/canvas/{id}/settings" hx-target="#modal-content" hx-swap="innerHTML">
Settings
</button>
<button class="btn btn-sm btn-danger" hx-delete="/api/canvas/{id}" hx-confirm="Delete this canvas?" hx-swap="none">
Delete
</button>
</div>
</div>"##
)
}
fn render_canvas_row(canvas: &DbCanvas, element_count: i64) -> String {
let name = html_escape(&canvas.name);
let description = canvas
.description
.as_deref()
.map(html_escape)
.unwrap_or_else(|| "-".to_string());
let updated = canvas.updated_at.format("%Y-%m-%d %H:%M").to_string();
let id = canvas.id;
let status = if canvas.is_public { "Public" } else { "Private" };
format!(
r##"<tr class="canvas-row" data-id="{id}">
<td class="canvas-name">
<a href="#" hx-get="/api/ui/canvas/{id}/editor" hx-target="#canvas-editor" hx-swap="innerHTML">{name}</a>
</td>
<td class="canvas-description">{description}</td>
<td class="canvas-elements">{element_count}</td>
<td class="canvas-status">{status}</td>
<td class="canvas-updated">{updated}</td>
<td class="canvas-actions">
<button class="btn btn-xs btn-primary" hx-get="/api/ui/canvas/{id}/editor" hx-target="#canvas-editor">Open</button>
<button class="btn btn-xs btn-danger" hx-delete="/api/canvas/{id}" hx-confirm="Delete?" hx-swap="none">Delete</button>
</td>
</tr>"##
)
}
pub async fn canvas_list(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let (org_id, bot_id) = get_bot_context(&state);
let mut q = canvases::table
.filter(canvases::org_id.eq(org_id))
.filter(canvases::bot_id.eq(bot_id))
.into_boxed();
if let Some(is_template) = query.is_template {
q = q.filter(canvases::is_template.eq(is_template));
}
if let Some(search) = &query.search {
let pattern = format!("%{search}%");
q = q.filter(
canvases::name
.ilike(pattern.clone())
.or(canvases::description.ilike(pattern)),
);
}
let db_canvases: Vec<DbCanvas> = match q
.order(canvases::updated_at.desc())
.limit(50)
.load(&mut conn)
{
Ok(c) => c,
Err(_) => {
return Html(render_empty_state("⚠️", "Error", "Failed to load canvases"));
}
};
if db_canvases.is_empty() {
return Html(render_empty_state(
"🎨",
"No Canvases",
"Create your first canvas to get started",
));
}
let mut rows = String::new();
for canvas in &db_canvases {
let element_count: i64 = canvas_elements::table
.filter(canvas_elements::canvas_id.eq(canvas.id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
rows.push_str(&render_canvas_row(canvas, element_count));
}
Html(format!(
r##"<table class="table canvas-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Elements</th>
<th>Status</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>"##
))
}
pub async fn canvas_cards(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let (org_id, bot_id) = get_bot_context(&state);
let mut q = canvases::table
.filter(canvases::org_id.eq(org_id))
.filter(canvases::bot_id.eq(bot_id))
.into_boxed();
if let Some(is_template) = query.is_template {
q = q.filter(canvases::is_template.eq(is_template));
}
if let Some(search) = &query.search {
let pattern = format!("%{search}%");
q = q.filter(
canvases::name
.ilike(pattern.clone())
.or(canvases::description.ilike(pattern)),
);
}
let db_canvases: Vec<DbCanvas> = match q
.order(canvases::updated_at.desc())
.limit(50)
.load(&mut conn)
{
Ok(c) => c,
Err(_) => {
return Html(render_empty_state("⚠️", "Error", "Failed to load canvases"));
}
};
if db_canvases.is_empty() {
return Html(render_empty_state(
"🎨",
"No Canvases",
"Create your first canvas to get started",
));
}
let mut cards = String::new();
for canvas in &db_canvases {
let element_count: i64 = canvas_elements::table
.filter(canvas_elements::canvas_id.eq(canvas.id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
cards.push_str(&render_canvas_card(canvas, element_count));
}
Html(format!(r##"<div class="canvas-grid">{cards}</div>"##))
}
pub async fn canvas_count(State(state): State<Arc<AppState>>) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html("0".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let count: i64 = canvases::table
.filter(canvases::org_id.eq(org_id))
.filter(canvases::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Html(count.to_string())
}
pub async fn canvas_templates_count(State(state): State<Arc<AppState>>) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html("0".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let count: i64 = canvases::table
.filter(canvases::org_id.eq(org_id))
.filter(canvases::bot_id.eq(bot_id))
.filter(canvases::is_template.eq(true))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Html(count.to_string())
}
pub async fn canvas_detail(
State(state): State<Arc<AppState>>,
Path(canvas_id): Path<Uuid>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let canvas: DbCanvas = match canvases::table
.filter(canvases::id.eq(canvas_id))
.first(&mut conn)
{
Ok(c) => c,
Err(_) => {
return Html(render_empty_state("", "Not Found", "Canvas not found"));
}
};
let elements: Vec<DbCanvasElement> = canvas_elements::table
.filter(canvas_elements::canvas_id.eq(canvas_id))
.order(canvas_elements::z_index.asc())
.load(&mut conn)
.unwrap_or_default();
let name = html_escape(&canvas.name);
let description = canvas
.description
.as_deref()
.map(html_escape)
.unwrap_or_else(|| "No description".to_string());
let bg_color = canvas.background_color.as_deref().unwrap_or("#ffffff");
let created = canvas.created_at.format("%Y-%m-%d %H:%M").to_string();
let updated = canvas.updated_at.format("%Y-%m-%d %H:%M").to_string();
let element_count = elements.len();
let status = if canvas.is_public { "Public" } else { "Private" };
let template_status = if canvas.is_template { "Yes" } else { "No" };
Html(format!(
r##"<div class="canvas-detail">
<div class="canvas-header">
<h2>{name}</h2>
<p class="canvas-description">{description}</p>
</div>
<div class="canvas-stats">
<div class="stat">
<span class="stat-label">Elements</span>
<span class="stat-value">{element_count}</span>
</div>
<div class="stat">
<span class="stat-label">Size</span>
<span class="stat-value">{width}x{height}</span>
</div>
<div class="stat">
<span class="stat-label">Background</span>
<span class="stat-value" style="background-color: {bg_color}; padding: 2px 8px;">{bg_color}</span>
</div>
<div class="stat">
<span class="stat-label">Status</span>
<span class="stat-value">{status}</span>
</div>
<div class="stat">
<span class="stat-label">Template</span>
<span class="stat-value">{template_status}</span>
</div>
</div>
<div class="canvas-dates">
<span>Created: {created}</span>
<span>Updated: {updated}</span>
</div>
<div class="canvas-actions">
<button class="btn btn-primary" hx-get="/api/ui/canvas/{canvas_id}/editor" hx-target="#canvas-editor" hx-swap="innerHTML">
Open Editor
</button>
<button class="btn btn-secondary" hx-get="/api/canvas/{canvas_id}/export" hx-vals='{{"format":"svg"}}' hx-target="#export-result">
Export SVG
</button>
<button class="btn btn-secondary" hx-get="/api/canvas/{canvas_id}/export" hx-vals='{{"format":"json"}}' hx-target="#export-result">
Export JSON
</button>
</div>
<div id="export-result"></div>
</div>"##,
width = canvas.width,
height = canvas.height
))
}
pub async fn canvas_editor(
State(state): State<Arc<AppState>>,
Path(canvas_id): Path<Uuid>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let canvas: DbCanvas = match canvases::table
.filter(canvases::id.eq(canvas_id))
.first(&mut conn)
{
Ok(c) => c,
Err(_) => {
return Html(render_empty_state("", "Not Found", "Canvas not found"));
}
};
let name = html_escape(&canvas.name);
let bg_color = canvas.background_color.as_deref().unwrap_or("#ffffff");
Html(format!(
r##"<div class="canvas-editor" data-canvas-id="{canvas_id}">
<div class="editor-toolbar">
<div class="toolbar-left">
<span class="canvas-title">{name}</span>
</div>
<div class="toolbar-center">
<button class="tool-btn" data-tool="select" title="Select">
<span>🔲</span>
</button>
<button class="tool-btn" data-tool="rectangle" title="Rectangle">
<span></span>
</button>
<button class="tool-btn" data-tool="ellipse" title="Ellipse">
<span></span>
</button>
<button class="tool-btn" data-tool="line" title="Line">
<span>📏</span>
</button>
<button class="tool-btn" data-tool="text" title="Text">
<span>📝</span>
</button>
<button class="tool-btn" data-tool="freehand" title="Freehand">
<span></span>
</button>
<button class="tool-btn" data-tool="sticky" title="Sticky Note">
<span>📌</span>
</button>
</div>
<div class="toolbar-right">
<button class="btn btn-sm" hx-post="/api/canvas/{canvas_id}/versions" hx-swap="none">
Save Version
</button>
<button class="btn btn-sm btn-secondary" hx-get="/api/ui/canvas/{canvas_id}" hx-target="#canvas-detail">
Close
</button>
</div>
</div>
<div class="editor-workspace">
<div class="canvas-container" style="background-color: {bg_color};">
<svg id="canvas-svg"
width="{width}"
height="{height}"
viewBox="0 0 {width} {height}"
hx-get="/api/ui/canvas/{canvas_id}/elements"
hx-trigger="load"
hx-swap="innerHTML">
</svg>
</div>
<div class="properties-panel" id="properties-panel">
<h4>Properties</h4>
<p class="text-muted">Select an element to edit its properties</p>
</div>
</div>
</div>"##,
width = canvas.width,
height = canvas.height
))
}
pub async fn canvas_elements_svg(
State(state): State<Arc<AppState>>,
Path(canvas_id): Path<Uuid>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(String::new());
};
let elements: Vec<DbCanvasElement> = canvas_elements::table
.filter(canvas_elements::canvas_id.eq(canvas_id))
.order(canvas_elements::z_index.asc())
.load(&mut conn)
.unwrap_or_default();
let mut svg_elements = String::new();
for el in &elements {
let svg = render_element_svg(el);
svg_elements.push_str(&svg);
}
Html(svg_elements)
}
fn render_element_svg(element: &DbCanvasElement) -> String {
let id = element.id;
let x = element.x;
let y = element.y;
let width = element.width;
let height = element.height;
let rotation = element.rotation;
let transform = if rotation != 0.0 {
format!(
r##" transform="rotate({rotation} {} {})""##,
x + width / 2.0,
y + height / 2.0
)
} else {
String::new()
};
let fill = element
.properties
.get("fill_color")
.and_then(|v| v.as_str())
.unwrap_or("transparent");
let stroke = element
.properties
.get("stroke_color")
.and_then(|v| v.as_str())
.unwrap_or("#000000");
let stroke_width = element
.properties
.get("stroke_width")
.and_then(|v| v.as_f64())
.unwrap_or(2.0);
let opacity = element
.properties
.get("opacity")
.and_then(|v| v.as_f64())
.unwrap_or(1.0);
match element.element_type.as_str() {
"rectangle" => {
let corner_radius = element
.properties
.get("corner_radius")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
format!(
r##"<rect data-element-id="{id}" x="{x}" y="{y}" width="{width}" height="{height}" rx="{corner_radius}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" opacity="{opacity}"{transform} class="canvas-element"/>"##
)
}
"ellipse" => {
let cx = x + width / 2.0;
let cy = y + height / 2.0;
let rx = width / 2.0;
let ry = height / 2.0;
format!(
r##"<ellipse data-element-id="{id}" cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" opacity="{opacity}"{transform} class="canvas-element"/>"##
)
}
"line" | "arrow" => {
let x2 = x + width;
let y2 = y + height;
let marker = if element.element_type == "arrow" {
r##" marker-end="url(#arrowhead)""##
} else {
""
};
format!(
r##"<line data-element-id="{id}" x1="{x}" y1="{y}" x2="{x2}" y2="{y2}" stroke="{stroke}" stroke-width="{stroke_width}" opacity="{opacity}"{marker}{transform} class="canvas-element"/>"##
)
}
"text" => {
let text = element
.properties
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("");
let font_size = element
.properties
.get("font_size")
.and_then(|v| v.as_f64())
.unwrap_or(16.0);
let font_family = element
.properties
.get("font_family")
.and_then(|v| v.as_str())
.unwrap_or("sans-serif");
let text_y = y + font_size;
format!(
r##"<text data-element-id="{id}" x="{x}" y="{text_y}" font-size="{font_size}" font-family="{font_family}" fill="{fill}" opacity="{opacity}"{transform} class="canvas-element">{text}</text>"##,
text = html_escape(text)
)
}
"freehand_path" => {
let path_data = element
.properties
.get("path_data")
.and_then(|v| v.as_str())
.unwrap_or("");
format!(
r##"<path data-element-id="{id}" d="{path_data}" fill="none" stroke="{stroke}" stroke-width="{stroke_width}" opacity="{opacity}"{transform} class="canvas-element"/>"##
)
}
"sticky" => {
let text = element
.properties
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("");
let bg = element
.properties
.get("fill_color")
.and_then(|v| v.as_str())
.unwrap_or("#ffeb3b");
format!(
r##"<g data-element-id="{id}" class="canvas-element sticky-note"{transform}>
<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="{bg}" stroke="#000" stroke-width="1"/>
<text x="{text_x}" y="{text_y}" font-size="14" fill="#000">{text}</text>
</g>"##,
text_x = x + 8.0,
text_y = y + 24.0,
text = html_escape(text)
)
}
_ => format!(
r##"<rect data-element-id="{id}" x="{x}" y="{y}" width="{width}" height="{height}" fill="{fill}" stroke="{stroke}" stroke-width="{stroke_width}" opacity="{opacity}"{transform} class="canvas-element"/>"##
),
}
}
pub async fn canvas_settings(
State(state): State<Arc<AppState>>,
Path(canvas_id): Path<Uuid>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let canvas: DbCanvas = match canvases::table
.filter(canvases::id.eq(canvas_id))
.first(&mut conn)
{
Ok(c) => c,
Err(_) => {
return Html(render_empty_state("", "Not Found", "Canvas not found"));
}
};
let name = html_escape(&canvas.name);
let description = canvas.description.as_deref().map(html_escape).unwrap_or_default();
let bg_color = canvas.background_color.as_deref().unwrap_or("#ffffff");
let is_public_checked = if canvas.is_public { "checked" } else { "" };
let is_template_checked = if canvas.is_template { "checked" } else { "" };
Html(format!(
r##"<div class="modal-header">
<h3>Canvas Settings</h3>
<button class="btn-close" onclick="closeModal()">&times;</button>
</div>
<form class="canvas-settings-form" hx-put="/api/canvas/{canvas_id}" hx-swap="none" hx-on::after-request="closeModal()">
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="{name}" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" rows="3">{description}</textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Width</label>
<input type="number" name="width" value="{width}" min="100" max="10000" />
</div>
<div class="form-group">
<label>Height</label>
<input type="number" name="height" value="{height}" min="100" max="10000" />
</div>
</div>
<div class="form-group">
<label>Background Color</label>
<input type="color" name="background_color" value="{bg_color}" />
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="is_public" value="true" {is_public_checked} />
Public (anyone can view)
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="is_template" value="true" {is_template_checked} />
Save as template
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>"##,
width = canvas.width,
height = canvas.height
))
}
pub async fn new_canvas_form(State(_state): State<Arc<AppState>>) -> Html<String> {
Html(
r##"<div class="modal-header">
<h3>New Canvas</h3>
<button class="btn-close" onclick="closeModal()">&times;</button>
</div>
<form class="canvas-form" hx-post="/api/canvas" hx-swap="none" hx-on::after-request="closeModal(); htmx.trigger('#canvas-list', 'refresh');">
<div class="form-group">
<label>Name</label>
<input type="text" name="name" placeholder="My Canvas" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" rows="3" placeholder="Describe your canvas..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Width</label>
<input type="number" name="width" value="1920" min="100" max="10000" />
</div>
<div class="form-group">
<label>Height</label>
<input type="number" name="height" value="1080" min="100" max="10000" />
</div>
</div>
<div class="form-group">
<label>Background Color</label>
<input type="color" name="background_color" value="#ffffff" />
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" name="is_template" value="true" />
Create as template
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Canvas</button>
</div>
</form>"##
.to_string(),
)
}
pub fn configure_canvas_ui_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/ui/canvas", get(canvas_list))
.route("/api/ui/canvas/cards", get(canvas_cards))
.route("/api/ui/canvas/count", get(canvas_count))
.route("/api/ui/canvas/templates/count", get(canvas_templates_count))
.route("/api/ui/canvas/new", get(new_canvas_form))
.route("/api/ui/canvas/{canvas_id}", get(canvas_detail))
.route("/api/ui/canvas/{canvas_id}/editor", get(canvas_editor))
.route("/api/ui/canvas/{canvas_id}/elements", get(canvas_elements_svg))
.route("/api/ui/canvas/{canvas_id}/settings", get(canvas_settings))
}

File diff suppressed because it is too large Load diff

1220
src/contacts/crm.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,16 @@ use axum::{
extract::{Query, State},
response::{Html, IntoResponse},
routing::get,
Json, Router,
Router,
};
use serde::{Deserialize, Serialize};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::contacts::crm::{CrmAccount, CrmContact, CrmLead, CrmOpportunity};
use crate::core::shared::schema::{crm_accounts, crm_contacts, crm_leads, crm_opportunities};
use crate::shared::state::AppState;
#[derive(Debug, Deserialize)]
@ -19,14 +24,13 @@ pub struct SearchQuery {
pub q: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CountResponse {
pub count: i64,
}
#[derive(Debug, Serialize)]
pub struct StatsResponse {
pub value: String,
fn get_bot_context(state: &AppState) -> (Uuid, Uuid) {
let Ok(mut conn) = state.conn.get() else {
return (Uuid::nil(), Uuid::nil());
};
let (bot_id, _bot_name) = get_default_bot(&mut conn);
let org_id = Uuid::nil();
(org_id, bot_id)
}
pub fn configure_crm_routes() -> Router<Arc<AppState>> {
@ -45,110 +49,561 @@ pub fn configure_crm_routes() -> Router<Arc<AppState>> {
}
async fn handle_crm_count(
State(_state): State<Arc<AppState>>,
State(state): State<Arc<AppState>>,
Query(query): Query<StageQuery>,
) -> impl IntoResponse {
let _stage = query.stage.unwrap_or_else(|| "all".to_string());
Html("0".to_string())
let Ok(mut conn) = state.conn.get() else {
return Html("0".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let stage = query.stage.unwrap_or_else(|| "all".to_string());
let count: i64 = if stage == "all" {
crm_leads::table
.filter(crm_leads::org_id.eq(org_id))
.filter(crm_leads::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0)
} else {
crm_leads::table
.filter(crm_leads::org_id.eq(org_id))
.filter(crm_leads::bot_id.eq(bot_id))
.filter(crm_leads::stage.eq(&stage))
.count()
.get_result(&mut conn)
.unwrap_or(0)
};
Html(count.to_string())
}
async fn handle_crm_pipeline(
State(_state): State<Arc<AppState>>,
State(state): State<Arc<AppState>>,
Query(query): Query<StageQuery>,
) -> impl IntoResponse {
let stage = query.stage.unwrap_or_else(|| "lead".to_string());
Html(format!(
r#"<div class="pipeline-empty">
<p>No {} items yet</p>
</div>"#,
stage
))
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_pipeline("lead"));
};
let (org_id, bot_id) = get_bot_context(&state);
let stage = query.stage.unwrap_or_else(|| "new".to_string());
let leads: Vec<CrmLead> = crm_leads::table
.filter(crm_leads::org_id.eq(org_id))
.filter(crm_leads::bot_id.eq(bot_id))
.filter(crm_leads::stage.eq(&stage))
.order(crm_leads::created_at.desc())
.limit(20)
.load(&mut conn)
.unwrap_or_default();
if leads.is_empty() {
return Html(render_empty_pipeline(&stage));
}
let mut html = String::new();
for lead in leads {
let value_str = lead
.value
.map(|v| format!("${}", v))
.unwrap_or_else(|| "-".to_string());
let contact_name = lead.contact_id.map(|_| "Contact").unwrap_or("-");
html.push_str(&format!(
"<div class=\"pipeline-card\" data-id=\"{}\">
<div class=\"pipeline-card-header\">
<span class=\"lead-title\">{}</span>
<span class=\"lead-value\">{}</span>
</div>
<div class=\"pipeline-card-body\">
<span class=\"lead-contact\">{}</span>
<span class=\"lead-probability\">{}%</span>
</div>
<div class=\"pipeline-card-actions\">
<button class=\"btn-sm\" hx-put=\"/api/crm/leads/{}/stage?stage=qualified\" hx-swap=\"none\">Qualify</button>
<button class=\"btn-sm btn-secondary\" hx-get=\"/api/crm/leads/{}\" hx-target=\"#detail-panel\">View</button>
</div>
</div>",
lead.id,
html_escape(&lead.title),
value_str,
contact_name,
lead.probability,
lead.id,
lead.id
));
}
Html(html)
}
async fn handle_crm_leads(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html(r#"<tr class="empty-row">
<td colspan="7" class="empty-state">
<div class="empty-icon">📋</div>
<p>No leads yet</p>
<p class="empty-hint">Create your first lead to get started</p>
async fn handle_crm_leads(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_table("leads", "📋", "No leads yet", "Create your first lead to get started"));
};
let (org_id, bot_id) = get_bot_context(&state);
let leads: Vec<CrmLead> = crm_leads::table
.filter(crm_leads::org_id.eq(org_id))
.filter(crm_leads::bot_id.eq(bot_id))
.filter(crm_leads::closed_at.is_null())
.order(crm_leads::created_at.desc())
.limit(50)
.load(&mut conn)
.unwrap_or_default();
if leads.is_empty() {
return Html(render_empty_table("leads", "📋", "No leads yet", "Create your first lead to get started"));
}
let mut html = String::new();
for lead in leads {
let value_str = lead
.value
.map(|v| format!("${}", v))
.unwrap_or_else(|| "-".to_string());
let expected_close = lead
.expected_close_date
.map(|d| d.to_string())
.unwrap_or_else(|| "-".to_string());
let source = lead.source.as_deref().unwrap_or("-");
html.push_str(&format!(
"<tr class=\"lead-row\" data-id=\"{}\">
<td><input type=\"checkbox\" class=\"row-select\" value=\"{}\"></td>
<td class=\"lead-title\">{}</td>
<td>{}</td>
<td><span class=\"stage-badge stage-{}\">{}</span></td>
<td>{}%</td>
<td>{}</td>
<td>{}</td>
<td class=\"actions\">
<button class=\"btn-icon\" hx-get=\"/api/crm/leads/{}\" hx-target=\"#detail-panel\" title=\"View\">👁</button>
<button class=\"btn-icon\" hx-delete=\"/api/crm/leads/{}\" hx-confirm=\"Delete this lead?\" hx-swap=\"none\" title=\"Delete\">🗑</button>
</td>
</tr>"#.to_string())
</tr>",
lead.id,
lead.id,
html_escape(&lead.title),
value_str,
lead.stage,
lead.stage,
lead.probability,
expected_close,
source,
lead.id,
lead.id
));
}
Html(html)
}
async fn handle_crm_opportunities(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html(r#"<tr class="empty-row">
<td colspan="7" class="empty-state">
<div class="empty-icon">💼</div>
<p>No opportunities yet</p>
<p class="empty-hint">Qualify leads to create opportunities</p>
async fn handle_crm_opportunities(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_table("opportunities", "💼", "No opportunities yet", "Qualify leads to create opportunities"));
};
let (org_id, bot_id) = get_bot_context(&state);
let opportunities: Vec<CrmOpportunity> = crm_opportunities::table
.filter(crm_opportunities::org_id.eq(org_id))
.filter(crm_opportunities::bot_id.eq(bot_id))
.filter(crm_opportunities::won.is_null())
.order(crm_opportunities::created_at.desc())
.limit(50)
.load(&mut conn)
.unwrap_or_default();
if opportunities.is_empty() {
return Html(render_empty_table("opportunities", "💼", "No opportunities yet", "Qualify leads to create opportunities"));
}
let mut html = String::new();
for opp in opportunities {
let value_str = opp
.value
.map(|v| format!("${}", v))
.unwrap_or_else(|| "-".to_string());
let expected_close = opp
.expected_close_date
.map(|d| d.to_string())
.unwrap_or_else(|| "-".to_string());
html.push_str(&format!(
"<tr class=\"opportunity-row\" data-id=\"{}\">
<td><input type=\"checkbox\" class=\"row-select\" value=\"{}\"></td>
<td class=\"opp-name\">{}</td>
<td>{}</td>
<td><span class=\"stage-badge stage-{}\">{}</span></td>
<td>{}%</td>
<td>{}</td>
<td class=\"actions\">
<button class=\"btn-icon btn-success\" hx-post=\"/api/crm/opportunities/{}/close\" hx-vals='{{\"won\":true}}' hx-swap=\"none\" title=\"Won\">✓</button>
<button class=\"btn-icon btn-danger\" hx-post=\"/api/crm/opportunities/{}/close\" hx-vals='{{\"won\":false}}' hx-swap=\"none\" title=\"Lost\">✗</button>
<button class=\"btn-icon\" hx-get=\"/api/crm/opportunities/{}\" hx-target=\"#detail-panel\" title=\"View\">👁</button>
</td>
</tr>"#.to_string())
</tr>",
opp.id,
opp.id,
html_escape(&opp.name),
value_str,
opp.stage,
opp.stage,
opp.probability,
expected_close,
opp.id,
opp.id,
opp.id
));
}
Html(html)
}
async fn handle_crm_contacts(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html(r#"<tr class="empty-row">
<td colspan="6" class="empty-state">
<div class="empty-icon">👥</div>
<p>No contacts yet</p>
<p class="empty-hint">Add contacts to your CRM</p>
async fn handle_crm_contacts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_table("contacts", "👥", "No contacts yet", "Add contacts to your CRM"));
};
let (org_id, bot_id) = get_bot_context(&state);
let contacts: Vec<CrmContact> = crm_contacts::table
.filter(crm_contacts::org_id.eq(org_id))
.filter(crm_contacts::bot_id.eq(bot_id))
.order(crm_contacts::created_at.desc())
.limit(50)
.load(&mut conn)
.unwrap_or_default();
if contacts.is_empty() {
return Html(render_empty_table("contacts", "👥", "No contacts yet", "Add contacts to your CRM"));
}
let mut html = String::new();
for contact in contacts {
let name = format!(
"{} {}",
contact.first_name.as_deref().unwrap_or(""),
contact.last_name.as_deref().unwrap_or("")
).trim().to_string();
let name = if name.is_empty() { "-".to_string() } else { name };
let email = contact.email.as_deref().unwrap_or("-");
let phone = contact.phone.as_deref().unwrap_or("-");
let company = contact.company.as_deref().unwrap_or("-");
html.push_str(&format!(
"<tr class=\"contact-row\" data-id=\"{}\">
<td><input type=\"checkbox\" class=\"row-select\" value=\"{}\"></td>
<td class=\"contact-name\">{}</td>
<td>{}</td>
<td>{}</td>
<td>{}</td>
<td><span class=\"status-badge status-{}\">{}</span></td>
<td class=\"actions\">
<button class=\"btn-icon\" hx-get=\"/api/crm/contacts/{}\" hx-target=\"#detail-panel\" title=\"View\">👁</button>
<button class=\"btn-icon\" hx-delete=\"/api/crm/contacts/{}\" hx-confirm=\"Delete this contact?\" hx-swap=\"none\" title=\"Delete\">🗑</button>
</td>
</tr>"#.to_string())
</tr>",
contact.id,
contact.id,
html_escape(&name),
html_escape(email),
html_escape(phone),
html_escape(company),
contact.status,
contact.status,
contact.id,
contact.id
));
}
Html(html)
}
async fn handle_crm_accounts(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html(r#"<tr class="empty-row">
<td colspan="6" class="empty-state">
<div class="empty-icon">🏢</div>
<p>No accounts yet</p>
<p class="empty-hint">Add company accounts to your CRM</p>
async fn handle_crm_accounts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_table("accounts", "🏢", "No accounts yet", "Add company accounts to your CRM"));
};
let (org_id, bot_id) = get_bot_context(&state);
let accounts: Vec<CrmAccount> = crm_accounts::table
.filter(crm_accounts::org_id.eq(org_id))
.filter(crm_accounts::bot_id.eq(bot_id))
.order(crm_accounts::created_at.desc())
.limit(50)
.load(&mut conn)
.unwrap_or_default();
if accounts.is_empty() {
return Html(render_empty_table("accounts", "🏢", "No accounts yet", "Add company accounts to your CRM"));
}
let mut html = String::new();
for account in accounts {
let website = account.website.as_deref().unwrap_or("-");
let industry = account.industry.as_deref().unwrap_or("-");
let employees = account
.employees_count
.map(|e| e.to_string())
.unwrap_or_else(|| "-".to_string());
let phone = account.phone.as_deref().unwrap_or("-");
html.push_str(&format!(
"<tr class=\"account-row\" data-id=\"{}\">
<td><input type=\"checkbox\" class=\"row-select\" value=\"{}\"></td>
<td class=\"account-name\">{}</td>
<td>{}</td>
<td>{}</td>
<td>{}</td>
<td>{}</td>
<td class=\"actions\">
<button class=\"btn-icon\" hx-get=\"/api/crm/accounts/{}\" hx-target=\"#detail-panel\" title=\"View\">👁</button>
<button class=\"btn-icon\" hx-delete=\"/api/crm/accounts/{}\" hx-confirm=\"Delete this account?\" hx-swap=\"none\" title=\"Delete\">🗑</button>
</td>
</tr>"#.to_string())
</tr>",
account.id,
account.id,
html_escape(&account.name),
html_escape(website),
html_escape(industry),
employees,
html_escape(phone),
account.id,
account.id
));
}
Html(html)
}
async fn handle_crm_search(
State(_state): State<Arc<AppState>>,
State(state): State<Arc<AppState>>,
Query(query): Query<SearchQuery>,
) -> impl IntoResponse {
let q = query.q.unwrap_or_default();
if q.is_empty() {
return Html(String::new());
}
Html(format!(
r#"<div class="search-results-empty">
<p>No results for "{}"</p>
</div>"#,
q
))
let Ok(mut conn) = state.conn.get() else {
return Html("<div class=\"search-results-empty\"><p>Search unavailable</p></div>".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let pattern = format!("%{q}%");
let contacts: Vec<CrmContact> = crm_contacts::table
.filter(crm_contacts::org_id.eq(org_id))
.filter(crm_contacts::bot_id.eq(bot_id))
.filter(
crm_contacts::first_name.ilike(&pattern)
.or(crm_contacts::last_name.ilike(&pattern))
.or(crm_contacts::email.ilike(&pattern))
.or(crm_contacts::company.ilike(&pattern))
)
.limit(10)
.load(&mut conn)
.unwrap_or_default();
let leads: Vec<CrmLead> = crm_leads::table
.filter(crm_leads::org_id.eq(org_id))
.filter(crm_leads::bot_id.eq(bot_id))
.filter(crm_leads::title.ilike(&pattern))
.limit(10)
.load(&mut conn)
.unwrap_or_default();
if contacts.is_empty() && leads.is_empty() {
return Html(format!(
"<div class=\"search-results-empty\"><p>No results for \"{}\"</p></div>",
html_escape(&q)
));
}
let mut html = String::from("<div class=\"search-results\">");
if !contacts.is_empty() {
html.push_str("<div class=\"search-section\"><h4>Contacts</h4>");
for contact in contacts {
let name = format!(
"{} {}",
contact.first_name.as_deref().unwrap_or(""),
contact.last_name.as_deref().unwrap_or("")
).trim().to_string();
let email = contact.email.as_deref().unwrap_or("");
html.push_str(&format!(
"<div class=\"search-result-item\" hx-get=\"/api/crm/contacts/{}\" hx-target=\"#detail-panel\">
<span class=\"result-name\">{}</span>
<span class=\"result-detail\">{}</span>
</div>",
contact.id,
html_escape(&name),
html_escape(email)
));
}
html.push_str("</div>");
}
if !leads.is_empty() {
html.push_str("<div class=\"search-section\"><h4>Leads</h4>");
for lead in leads {
let value = lead.value.map(|v| format!("${}", v)).unwrap_or_default();
html.push_str(&format!(
"<div class=\"search-result-item\" hx-get=\"/api/crm/leads/{}\" hx-target=\"#detail-panel\">
<span class=\"result-name\">{}</span>
<span class=\"result-detail\">{}</span>
</div>",
lead.id,
html_escape(&lead.title),
value
));
}
html.push_str("</div>");
}
html.push_str("</div>");
Html(html)
}
async fn handle_conversion_rate(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html("0%".to_string())
async fn handle_conversion_rate(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("0%".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let total_leads: i64 = crm_leads::table
.filter(crm_leads::org_id.eq(org_id))
.filter(crm_leads::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let won_opportunities: i64 = crm_opportunities::table
.filter(crm_opportunities::org_id.eq(org_id))
.filter(crm_opportunities::bot_id.eq(bot_id))
.filter(crm_opportunities::won.eq(Some(true)))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let rate = if total_leads > 0 {
(won_opportunities as f64 / total_leads as f64) * 100.0
} else {
0.0
};
Html(format!("{:.1}%", rate))
}
async fn handle_pipeline_value(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html("$0".to_string())
async fn handle_pipeline_value(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("$0".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let opportunities: Vec<CrmOpportunity> = crm_opportunities::table
.filter(crm_opportunities::org_id.eq(org_id))
.filter(crm_opportunities::bot_id.eq(bot_id))
.filter(crm_opportunities::won.is_null())
.load(&mut conn)
.unwrap_or_default();
let total: f64 = opportunities
.iter()
.filter_map(|o| o.value.as_ref())
.filter_map(|v| v.to_string().parse::<f64>().ok())
.sum();
Html(format_currency(total))
}
async fn handle_avg_deal(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html("$0".to_string())
async fn handle_avg_deal(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("$0".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let won_opportunities: Vec<CrmOpportunity> = crm_opportunities::table
.filter(crm_opportunities::org_id.eq(org_id))
.filter(crm_opportunities::bot_id.eq(bot_id))
.filter(crm_opportunities::won.eq(Some(true)))
.load(&mut conn)
.unwrap_or_default();
if won_opportunities.is_empty() {
return Html("$0".to_string());
}
let total: f64 = won_opportunities
.iter()
.filter_map(|o| o.value.as_ref())
.filter_map(|v| v.to_string().parse::<f64>().ok())
.sum();
let avg = total / won_opportunities.len() as f64;
Html(format_currency(avg))
}
async fn handle_won_month(
State(_state): State<Arc<AppState>>,
) -> impl IntoResponse {
Html("0".to_string())
async fn handle_won_month(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("0".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let count: i64 = crm_opportunities::table
.filter(crm_opportunities::org_id.eq(org_id))
.filter(crm_opportunities::bot_id.eq(bot_id))
.filter(crm_opportunities::won.eq(Some(true)))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Html(count.to_string())
}
fn render_empty_pipeline(stage: &str) -> String {
format!(
"<div class=\"pipeline-empty\"><p>No {} items yet</p></div>",
stage
)
}
fn render_empty_table(_entity: &str, icon: &str, title: &str, hint: &str) -> String {
format!(
"<tr class=\"empty-row\">
<td colspan=\"7\" class=\"empty-state\">
<div class=\"empty-icon\">{}</div>
<p>{}</p>
<p class=\"empty-hint\">{}</p>
</td>
</tr>",
icon, title, hint
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
fn format_currency(value: f64) -> String {
if value >= 1_000_000.0 {
format!("${:.1}M", value / 1_000_000.0)
} else if value >= 1_000.0 {
format!("${:.1}K", value / 1_000.0)
} else {
format!("${:.0}", value)
}
}

View file

@ -1,4 +1,5 @@
pub mod calendar_integration;
pub mod crm;
pub mod crm_ui;
pub mod external_sync;
pub mod tasks_integration;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
#![recursion_limit = "512"]
pub mod auto_task;
pub mod basic;
pub mod billing;
@ -10,9 +12,12 @@ pub mod embedded_ui;
pub mod maintenance;
pub mod multimodal;
pub mod player;
pub mod people;
pub mod products;
pub mod search;
pub mod security;
pub mod tickets;
pub mod attendant;
pub mod analytics;
pub mod designer;

View file

@ -358,6 +358,7 @@ async fn run_axum_server(
#[cfg(feature = "calendar")]
{
api_router = api_router.merge(crate::calendar::configure_calendar_routes());
api_router = api_router.merge(crate::calendar::ui::configure_calendar_ui_routes());
}
api_router = api_router.merge(botserver::analytics::configure_analytics_routes());
@ -371,6 +372,8 @@ async fn run_axum_server(
api_router = api_router.merge(botserver::sources::configure_sources_routes());
api_router = api_router.merge(botserver::designer::configure_designer_routes());
api_router = api_router.merge(botserver::dashboards::configure_dashboards_routes());
api_router = api_router.merge(botserver::legal::configure_legal_routes());
api_router = api_router.merge(botserver::compliance::configure_compliance_routes());
api_router = api_router.merge(botserver::monitoring::configure());
api_router = api_router.merge(botserver::security::configure_protection_routes());
api_router = api_router.merge(botserver::settings::configure_settings_routes());
@ -379,14 +382,26 @@ async fn run_axum_server(
api_router = api_router.merge(botserver::auto_task::configure_autotask_routes());
api_router = api_router.merge(crate::core::shared::admin::configure());
api_router = api_router.merge(botserver::workspaces::configure_workspaces_routes());
api_router = api_router.merge(botserver::workspaces::ui::configure_workspaces_ui_routes());
api_router = api_router.merge(botserver::project::configure());
api_router = api_router.merge(botserver::analytics::goals::configure_goals_routes());
api_router = api_router.merge(botserver::analytics::goals_ui::configure_goals_ui_routes());
api_router = api_router.merge(botserver::player::configure_player_routes());
api_router = api_router.merge(botserver::canvas::configure_canvas_routes());
api_router = api_router.merge(botserver::canvas::ui::configure_canvas_ui_routes());
api_router = api_router.merge(botserver::social::configure_social_routes());
api_router = api_router.merge(botserver::contacts::crm_ui::configure_crm_routes());
api_router = api_router.merge(botserver::contacts::crm::configure_crm_api_routes());
api_router = api_router.merge(botserver::billing::billing_ui::configure_billing_routes());
api_router = api_router.merge(botserver::billing::api::configure_billing_api_routes());
api_router = api_router.merge(botserver::products::configure_products_routes());
api_router = api_router.merge(botserver::products::api::configure_products_api_routes());
api_router = api_router.merge(botserver::tickets::configure_tickets_routes());
api_router = api_router.merge(botserver::tickets::ui::configure_tickets_ui_routes());
api_router = api_router.merge(botserver::people::configure_people_routes());
api_router = api_router.merge(botserver::people::ui::configure_people_ui_routes());
api_router = api_router.merge(botserver::attendant::configure_attendant_routes());
api_router = api_router.merge(botserver::attendant::ui::configure_attendant_ui_routes());
#[cfg(feature = "whatsapp")]
{

1087
src/people/mod.rs Normal file

File diff suppressed because it is too large Load diff

836
src/people/ui.rs Normal file
View file

@ -0,0 +1,836 @@
use axum::{
extract::{Path, Query, State},
response::Html,
routing::get,
Router,
};
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{people, people_departments, people_teams, people_time_off};
use crate::shared::state::AppState;
#[derive(Debug, Deserialize, Default)]
pub struct PeopleQuery {
pub department: Option<String>,
pub team_id: Option<Uuid>,
pub is_active: Option<bool>,
pub search: Option<String>,
pub limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
pub fn configure_people_ui_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/ui/people", get(handle_people_list))
.route("/api/ui/people/count", get(handle_people_count))
.route("/api/ui/people/active-count", get(handle_active_count))
.route("/api/ui/people/cards", get(handle_people_cards))
.route("/api/ui/people/search", get(handle_people_search))
.route("/api/ui/people/:id", get(handle_person_detail))
.route("/api/ui/people/departments", get(handle_departments_list))
.route("/api/ui/people/teams", get(handle_teams_list))
.route("/api/ui/people/time-off", get(handle_time_off_list))
.route("/api/ui/people/stats", get(handle_people_stats))
.route("/api/ui/people/new", get(handle_new_person_form))
}
async fn handle_people_list(
State(state): State<Arc<AppState>>,
Query(query): Query<PeopleQuery>,
) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let mut db_query = people::table
.filter(people::bot_id.eq(bot_id))
.into_boxed();
if let Some(ref dept) = query.department {
db_query = db_query.filter(people::department.eq(dept));
}
if let Some(is_active) = query.is_active {
db_query = db_query.filter(people::is_active.eq(is_active));
}
if let Some(ref search) = query.search {
let term = format!("%{search}%");
db_query = db_query.filter(
people::first_name.ilike(&term)
.or(people::last_name.ilike(&term))
.or(people::email.ilike(&term))
);
}
db_query = db_query.order(people::first_name.asc());
let limit = query.limit.unwrap_or(50);
db_query = db_query.limit(limit);
db_query
.select((
people::id,
people::first_name,
people::last_name,
people::email,
people::job_title,
people::department,
people::avatar_url,
people::is_active,
))
.load::<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, bool)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(persons) if !persons.is_empty() => {
let mut html = String::from(
r##"<table class="people-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Job Title</th>
<th>Department</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>"##
);
for (id, first_name, last_name, email, job_title, department, avatar_url, is_active) in persons {
let full_name = format!("{} {}", first_name, last_name.unwrap_or_default());
let email_str = email.unwrap_or_else(|| "-".to_string());
let title_str = job_title.unwrap_or_else(|| "-".to_string());
let dept_str = department.unwrap_or_else(|| "-".to_string());
let avatar = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
let status_class = if is_active { "status-active" } else { "status-inactive" };
let status_text = if is_active { "Active" } else { "Inactive" };
html.push_str(&format!(
r##"<tr class="person-row" data-id="{id}">
<td class="person-name">
<img src="{}" class="avatar-sm" alt="" />
<span>{}</span>
</td>
<td class="person-email">{}</td>
<td class="person-title">{}</td>
<td class="person-department">{}</td>
<td class="person-status"><span class="{}">{}</span></td>
<td class="person-actions">
<button class="btn-sm" hx-get="/api/ui/people/{id}" hx-target="#person-detail">View</button>
<button class="btn-sm btn-secondary" hx-get="/api/people/{id}/edit" hx-target="#modal-content">Edit</button>
</td>
</tr>"##,
html_escape(&avatar),
html_escape(&full_name),
html_escape(&email_str),
html_escape(&title_str),
html_escape(&dept_str),
status_class,
status_text
));
}
html.push_str("</tbody></table>");
Html(html)
}
_ => Html(
r##"<div class="empty-state">
<div class="empty-icon">👥</div>
<p>No people found</p>
<p class="empty-hint">Add people to your directory</p>
<button class="btn btn-primary" hx-get="/api/ui/people/new" hx-target="#modal-content">Add Person</button>
</div>"##.to_string(),
),
}
}
async fn handle_people_cards(
State(state): State<Arc<AppState>>,
Query(query): Query<PeopleQuery>,
) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let mut db_query = people::table
.filter(people::bot_id.eq(bot_id))
.filter(people::is_active.eq(true))
.into_boxed();
if let Some(ref dept) = query.department {
db_query = db_query.filter(people::department.eq(dept));
}
db_query = db_query.order(people::first_name.asc());
let limit = query.limit.unwrap_or(20);
db_query = db_query.limit(limit);
db_query
.select((
people::id,
people::first_name,
people::last_name,
people::email,
people::job_title,
people::department,
people::avatar_url,
people::phone,
))
.load::<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(persons) if !persons.is_empty() => {
let mut html = String::from(r##"<div class="people-cards-grid">"##);
for (id, first_name, last_name, email, job_title, department, avatar_url, phone) in persons {
let full_name = format!("{} {}", first_name, last_name.unwrap_or_default());
let email_str = email.unwrap_or_default();
let title_str = job_title.unwrap_or_else(|| "Team Member".to_string());
let dept_str = department.unwrap_or_default();
let avatar = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
let phone_str = phone.unwrap_or_default();
html.push_str(&format!(
r##"<div class="person-card" data-id="{id}" hx-get="/api/ui/people/{id}" hx-target="#person-detail">
<div class="card-avatar">
<img src="{}" alt="{}" class="avatar-lg" />
</div>
<div class="card-info">
<h4 class="card-name">{}</h4>
<p class="card-title">{}</p>
<p class="card-department">{}</p>
</div>
<div class="card-contact">
<span class="card-email">{}</span>
<span class="card-phone">{}</span>
</div>
</div>"##,
html_escape(&avatar),
html_escape(&full_name),
html_escape(&full_name),
html_escape(&title_str),
html_escape(&dept_str),
html_escape(&email_str),
html_escape(&phone_str)
));
}
html.push_str("</div>");
Html(html)
}
_ => Html(
r##"<div class="empty-state">
<div class="empty-icon">👥</div>
<p>No people in directory</p>
</div>"##.to_string(),
),
}
}
async fn handle_people_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
people::table
.filter(people::bot_id.eq(bot_id))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
async fn handle_active_count(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
people::table
.filter(people::bot_id.eq(bot_id))
.filter(people::is_active.eq(true))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
async fn handle_person_detail(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
people::table
.find(id)
.select((
people::id,
people::first_name,
people::last_name,
people::email,
people::phone,
people::mobile,
people::job_title,
people::department,
people::office_location,
people::avatar_url,
people::bio,
people::hire_date,
people::is_active,
people::last_seen_at,
))
.first::<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<chrono::NaiveDate>, bool, Option<DateTime<Utc>>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some((id, first_name, last_name, email, phone, mobile, job_title, department, office, avatar_url, bio, hire_date, is_active, last_seen)) => {
let full_name = format!("{} {}", first_name, last_name.unwrap_or_default());
let email_str = email.unwrap_or_else(|| "-".to_string());
let phone_str = phone.unwrap_or_else(|| "-".to_string());
let mobile_str = mobile.unwrap_or_else(|| "-".to_string());
let title_str = job_title.unwrap_or_else(|| "-".to_string());
let dept_str = department.unwrap_or_else(|| "-".to_string());
let office_str = office.unwrap_or_else(|| "-".to_string());
let avatar = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
let bio_str = bio.unwrap_or_else(|| "No bio available".to_string());
let hire_str = hire_date.map(|d| d.format("%B %d, %Y").to_string()).unwrap_or_else(|| "-".to_string());
let last_seen_str = last_seen.map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_else(|| "Never".to_string());
let status_class = if is_active { "status-active" } else { "status-inactive" };
let status_text = if is_active { "Active" } else { "Inactive" };
Html(format!(
r##"<div class="person-detail-card">
<div class="detail-header">
<img src="{}" alt="{}" class="avatar-xl" />
<div class="header-info">
<h2>{}</h2>
<p class="job-title">{}</p>
<span class="{}">{}</span>
</div>
</div>
<div class="detail-section">
<h4>Contact Information</h4>
<div class="detail-grid">
<div class="detail-item">
<label>Email</label>
<span><a href="mailto:{}">{}</a></span>
</div>
<div class="detail-item">
<label>Phone</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Mobile</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Office</label>
<span>{}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Work Information</h4>
<div class="detail-grid">
<div class="detail-item">
<label>Department</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Hire Date</label>
<span>{}</span>
</div>
<div class="detail-item">
<label>Last Seen</label>
<span>{}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Bio</h4>
<p class="bio-text">{}</p>
</div>
<div class="detail-actions">
<button class="btn btn-primary" hx-get="/api/people/{}/edit" hx-target="#modal-content">Edit</button>
<button class="btn btn-secondary" hx-get="/api/ui/people/{}/reports" hx-target="#reports-panel">View Reports</button>
<button class="btn btn-danger" hx-delete="/api/people/{}" hx-swap="none" hx-confirm="Deactivate this person?">Deactivate</button>
</div>
</div>"##,
html_escape(&avatar),
html_escape(&full_name),
html_escape(&full_name),
html_escape(&title_str),
status_class,
status_text,
html_escape(&email_str),
html_escape(&email_str),
html_escape(&phone_str),
html_escape(&mobile_str),
html_escape(&office_str),
html_escape(&dept_str),
hire_str,
last_seen_str,
html_escape(&bio_str),
id, id, id
))
}
None => Html(
r##"<div class="empty-state">
<p>Person not found</p>
</div>"##.to_string(),
),
}
}
async fn handle_departments_list(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
people_departments::table
.filter(people_departments::bot_id.eq(bot_id))
.filter(people_departments::is_active.eq(true))
.order(people_departments::name.asc())
.select((
people_departments::id,
people_departments::name,
people_departments::description,
people_departments::code,
))
.load::<(Uuid, String, Option<String>, Option<String>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(depts) if !depts.is_empty() => {
let mut html = String::from(r##"<div class="departments-list">"##);
for (id, name, description, code) in depts {
let desc_str = description.unwrap_or_default();
let code_str = code.unwrap_or_default();
html.push_str(&format!(
r##"<div class="department-item" data-id="{id}">
<div class="dept-header">
<span class="dept-name">{}</span>
<span class="dept-code">{}</span>
</div>
<p class="dept-description">{}</p>
<button class="btn-sm" hx-get="/api/ui/people?department={}" hx-target="#people-list">View Members</button>
</div>"##,
html_escape(&name),
html_escape(&code_str),
html_escape(&desc_str),
html_escape(&name)
));
}
html.push_str("</div>");
Html(html)
}
_ => Html(
r##"<div class="empty-state">
<p>No departments yet</p>
</div>"##.to_string(),
),
}
}
async fn handle_teams_list(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
people_teams::table
.filter(people_teams::bot_id.eq(bot_id))
.filter(people_teams::is_active.eq(true))
.order(people_teams::name.asc())
.select((
people_teams::id,
people_teams::name,
people_teams::description,
people_teams::color,
))
.load::<(Uuid, String, Option<String>, Option<String>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(teams) if !teams.is_empty() => {
let mut html = String::from(r##"<div class="teams-list">"##);
for (id, name, description, color) in teams {
let desc_str = description.unwrap_or_default();
let team_color = color.unwrap_or_else(|| "#3b82f6".to_string());
html.push_str(&format!(
r##"<div class="team-item" data-id="{id}" style="border-left: 4px solid {};">
<div class="team-header">
<span class="team-name">{}</span>
</div>
<p class="team-description">{}</p>
<button class="btn-sm" hx-get="/api/ui/people?team_id={id}" hx-target="#people-list">View Members</button>
</div>"##,
team_color,
html_escape(&name),
html_escape(&desc_str)
));
}
html.push_str("</div>");
Html(html)
}
_ => Html(
r##"<div class="empty-state">
<p>No teams yet</p>
</div>"##.to_string(),
),
}
}
async fn handle_time_off_list(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
people_time_off::table
.filter(people_time_off::bot_id.eq(bot_id))
.filter(people_time_off::status.eq("pending"))
.order(people_time_off::created_at.desc())
.limit(20)
.select((
people_time_off::id,
people_time_off::person_id,
people_time_off::time_off_type,
people_time_off::status,
people_time_off::start_date,
people_time_off::end_date,
people_time_off::reason,
))
.load::<(Uuid, Uuid, String, String, chrono::NaiveDate, chrono::NaiveDate, Option<String>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(requests) if !requests.is_empty() => {
let mut html = String::from(r##"<div class="time-off-list">"##);
for (id, person_id, time_off_type, status, start_date, end_date, reason) in requests {
let reason_str = reason.unwrap_or_default();
let start_str = start_date.format("%b %d").to_string();
let end_str = end_date.format("%b %d, %Y").to_string();
html.push_str(&format!(
r##"<div class="time-off-item" data-id="{id}">
<div class="time-off-header">
<span class="time-off-type">{}</span>
<span class="time-off-status status-{}">{}</span>
</div>
<div class="time-off-dates">
<span>{} - {}</span>
</div>
<p class="time-off-reason">{}</p>
<div class="time-off-actions">
<button class="btn-sm btn-success" hx-put="/api/people/time-off/{id}/approve" hx-swap="none">Approve</button>
<button class="btn-sm btn-danger" hx-put="/api/people/time-off/{id}/reject" hx-swap="none">Reject</button>
</div>
</div>"##,
html_escape(&time_off_type),
status,
html_escape(&status),
start_str,
end_str,
html_escape(&reason_str)
));
}
html.push_str("</div>");
Html(html)
}
_ => Html(
r##"<div class="empty-state">
<p>No pending time-off requests</p>
</div>"##.to_string(),
),
}
}
async fn handle_people_search(
State(state): State<Arc<AppState>>,
Query(query): Query<SearchQuery>,
) -> Html<String> {
let q = query.q.clone().unwrap_or_default();
if q.is_empty() {
return Html(String::new());
}
let pool = state.conn.clone();
let search_term = format!("%{q}%");
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
people::table
.filter(people::bot_id.eq(bot_id))
.filter(
people::first_name.ilike(&search_term)
.or(people::last_name.ilike(&search_term))
.or(people::email.ilike(&search_term))
.or(people::job_title.ilike(&search_term))
)
.order(people::first_name.asc())
.limit(20)
.select((
people::id,
people::first_name,
people::last_name,
people::email,
people::job_title,
people::avatar_url,
))
.load::<(Uuid, String, Option<String>, Option<String>, Option<String>, Option<String>)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(persons) if !persons.is_empty() => {
let mut html = String::from(r##"<div class="search-results">"##);
for (id, first_name, last_name, email, job_title, avatar_url) in persons {
let full_name = format!("{} {}", first_name, last_name.unwrap_or_default());
let email_str = email.unwrap_or_default();
let title_str = job_title.unwrap_or_default();
let avatar = avatar_url.unwrap_or_else(|| "/assets/default-avatar.png".to_string());
html.push_str(&format!(
r##"<div class="search-result-item" hx-get="/api/ui/people/{id}" hx-target="#person-detail">
<img src="{}" class="avatar-sm" alt="" />
<div class="result-info">
<span class="result-name">{}</span>
<span class="result-title">{}</span>
<span class="result-email">{}</span>
</div>
</div>"##,
html_escape(&avatar),
html_escape(&full_name),
html_escape(&title_str),
html_escape(&email_str)
));
}
html.push_str("</div>");
Html(html)
}
_ => Html(format!(
r##"<div class="search-results-empty">
<p>No results for "{}"</p>
</div>"##,
html_escape(&q)
)),
}
}
async fn handle_people_stats(State(state): State<Arc<AppState>>) -> Html<String> {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let total: i64 = people::table
.filter(people::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let active: i64 = people::table
.filter(people::bot_id.eq(bot_id))
.filter(people::is_active.eq(true))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let departments: i64 = people_departments::table
.filter(people_departments::bot_id.eq(bot_id))
.filter(people_departments::is_active.eq(true))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let teams: i64 = people_teams::table
.filter(people_teams::bot_id.eq(bot_id))
.filter(people_teams::is_active.eq(true))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let pending_time_off: i64 = people_time_off::table
.filter(people_time_off::bot_id.eq(bot_id))
.filter(people_time_off::status.eq("pending"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Some((total, active, departments, teams, pending_time_off))
})
.await
.ok()
.flatten();
match result {
Some((total, active, departments, teams, pending_time_off)) => Html(format!(
r##"<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">{total}</span>
<span class="stat-label">Total People</span>
</div>
<div class="stat-card stat-success">
<span class="stat-value">{active}</span>
<span class="stat-label">Active</span>
</div>
<div class="stat-card stat-info">
<span class="stat-value">{departments}</span>
<span class="stat-label">Departments</span>
</div>
<div class="stat-card stat-primary">
<span class="stat-value">{teams}</span>
<span class="stat-label">Teams</span>
</div>
<div class="stat-card stat-warning">
<span class="stat-value">{pending_time_off}</span>
<span class="stat-label">Pending Time Off</span>
</div>
</div>"##
)),
None => Html(r##"<div class="stats-grid"><div class="stat-card"><span class="stat-value">-</span></div></div>"##.to_string()),
}
}
async fn handle_new_person_form() -> Html<String> {
Html(r##"<div class="modal-header">
<h3>Add New Person</h3>
<button class="btn-close" onclick="closeModal()">&times;</button>
</div>
<form class="person-form" hx-post="/api/people" hx-swap="none">
<div class="form-row">
<div class="form-group">
<label>First Name *</label>
<input type="text" name="first_name" required />
</div>
<div class="form-group">
<label>Last Name</label>
<input type="text" name="last_name" />
</div>
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" />
</div>
<div class="form-row">
<div class="form-group">
<label>Phone</label>
<input type="tel" name="phone" />
</div>
<div class="form-group">
<label>Mobile</label>
<input type="tel" name="mobile" />
</div>
</div>
<div class="form-group">
<label>Job Title</label>
<input type="text" name="job_title" />
</div>
<div class="form-group">
<label>Department</label>
<input type="text" name="department" />
</div>
<div class="form-group">
<label>Office Location</label>
<input type="text" name="office_location" />
</div>
<div class="form-group">
<label>Hire Date</label>
<input type="date" name="hire_date" />
</div>
<div class="form-group">
<label>Bio</label>
<textarea name="bio" rows="3" placeholder="Short biography"></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Add Person</button>
</div>
</form>"##.to_string())
}

969
src/products/api.rs Normal file
View file

@ -0,0 +1,969 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, put},
Json, Router,
};
use bigdecimal::BigDecimal;
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{
inventory_movements, price_list_items, price_lists, product_categories, product_variants,
products, services,
};
use crate::shared::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = products)]
pub struct Product {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub sku: Option<String>,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub product_type: String,
pub price: BigDecimal,
pub cost: Option<BigDecimal>,
pub currency: String,
pub tax_rate: BigDecimal,
pub unit: String,
pub stock_quantity: i32,
pub low_stock_threshold: i32,
pub is_active: bool,
pub images: serde_json::Value,
pub attributes: serde_json::Value,
pub weight: Option<BigDecimal>,
pub dimensions: Option<serde_json::Value>,
pub barcode: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = services)]
pub struct Service {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub service_type: String,
pub hourly_rate: Option<BigDecimal>,
pub fixed_price: Option<BigDecimal>,
pub currency: String,
pub duration_minutes: Option<i32>,
pub is_active: bool,
pub attributes: serde_json::Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
#[diesel(table_name = product_categories)]
pub struct ProductCategory {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub name: String,
pub description: Option<String>,
pub parent_id: Option<Uuid>,
pub slug: Option<String>,
pub image_url: Option<String>,
pub sort_order: i32,
pub is_active: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = price_lists)]
pub struct PriceList {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub name: String,
pub description: Option<String>,
pub currency: String,
pub is_default: bool,
pub valid_from: Option<chrono::NaiveDate>,
pub valid_until: Option<chrono::NaiveDate>,
pub customer_group: Option<String>,
pub discount_percent: BigDecimal,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
#[diesel(table_name = price_list_items)]
pub struct PriceListItem {
pub id: Uuid,
pub price_list_id: Uuid,
pub product_id: Option<Uuid>,
pub service_id: Option<Uuid>,
pub price: BigDecimal,
pub min_quantity: i32,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
#[diesel(table_name = inventory_movements)]
pub struct InventoryMovement {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub product_id: Uuid,
pub movement_type: String,
pub quantity: i32,
pub reference_type: Option<String>,
pub reference_id: Option<Uuid>,
pub notes: Option<String>,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
#[diesel(table_name = product_variants)]
pub struct ProductVariant {
pub id: Uuid,
pub product_id: Uuid,
pub sku: Option<String>,
pub name: String,
pub price_adjustment: BigDecimal,
pub stock_quantity: i32,
pub attributes: serde_json::Value,
pub is_active: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateProductRequest {
pub name: String,
pub description: Option<String>,
pub sku: Option<String>,
pub category: Option<String>,
pub product_type: Option<String>,
pub price: f64,
pub cost: Option<f64>,
pub currency: Option<String>,
pub tax_rate: Option<f64>,
pub unit: Option<String>,
pub stock_quantity: Option<i32>,
pub low_stock_threshold: Option<i32>,
pub images: Option<Vec<String>>,
pub barcode: Option<String>,
pub weight: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateProductRequest {
pub name: Option<String>,
pub description: Option<String>,
pub sku: Option<String>,
pub category: Option<String>,
pub price: Option<f64>,
pub cost: Option<f64>,
pub tax_rate: Option<f64>,
pub unit: Option<String>,
pub stock_quantity: Option<i32>,
pub low_stock_threshold: Option<i32>,
pub is_active: Option<bool>,
pub barcode: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateServiceRequest {
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub service_type: Option<String>,
pub hourly_rate: Option<f64>,
pub fixed_price: Option<f64>,
pub currency: Option<String>,
pub duration_minutes: Option<i32>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateServiceRequest {
pub name: Option<String>,
pub description: Option<String>,
pub category: Option<String>,
pub hourly_rate: Option<f64>,
pub fixed_price: Option<f64>,
pub duration_minutes: Option<i32>,
pub is_active: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCategoryRequest {
pub name: String,
pub description: Option<String>,
pub parent_id: Option<Uuid>,
pub slug: Option<String>,
pub image_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePriceListRequest {
pub name: String,
pub description: Option<String>,
pub currency: Option<String>,
pub discount_percent: Option<f64>,
pub customer_group: Option<String>,
pub valid_from: Option<String>,
pub valid_until: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AdjustStockRequest {
pub quantity: i32,
pub movement_type: String,
pub notes: Option<String>,
pub reference_type: Option<String>,
pub reference_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub search: Option<String>,
pub category: Option<String>,
pub is_active: Option<bool>,
pub low_stock: Option<bool>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct ProductStats {
pub total_products: i64,
pub active_products: i64,
pub total_services: i64,
pub active_services: i64,
pub low_stock_count: i64,
pub total_stock_value: f64,
pub categories_count: i64,
pub price_lists_count: i64,
}
#[derive(Debug, Serialize)]
pub struct ProductWithVariants {
pub product: Product,
pub variants: Vec<ProductVariant>,
}
fn get_bot_context(state: &AppState) -> (Uuid, Uuid) {
let Ok(mut conn) = state.conn.get() else {
return (Uuid::nil(), Uuid::nil());
};
let (bot_id, _bot_name) = get_default_bot(&mut conn);
let org_id = Uuid::nil();
(org_id, bot_id)
}
fn bd(val: f64) -> BigDecimal {
BigDecimal::from_str(&val.to_string()).unwrap_or_else(|_| BigDecimal::from(0))
}
fn bd_to_f64(val: &BigDecimal) -> f64 {
val.to_string().parse::<f64>().unwrap_or(0.0)
}
pub async fn create_product(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateProductRequest>,
) -> Result<Json<Product>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let id = Uuid::new_v4();
let now = Utc::now();
let product = Product {
id,
org_id,
bot_id,
sku: req.sku,
name: req.name,
description: req.description,
category: req.category,
product_type: req.product_type.unwrap_or_else(|| "physical".to_string()),
price: bd(req.price),
cost: req.cost.map(bd),
currency: req.currency.unwrap_or_else(|| "USD".to_string()),
tax_rate: bd(req.tax_rate.unwrap_or(0.0)),
unit: req.unit.unwrap_or_else(|| "unit".to_string()),
stock_quantity: req.stock_quantity.unwrap_or(0),
low_stock_threshold: req.low_stock_threshold.unwrap_or(10),
is_active: true,
images: serde_json::json!(req.images.unwrap_or_default()),
attributes: serde_json::json!({}),
weight: req.weight.map(bd),
dimensions: None,
barcode: req.barcode,
created_at: now,
updated_at: now,
};
diesel::insert_into(products::table)
.values(&product)
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
Ok(Json(product))
}
pub async fn list_products(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<Product>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let limit = query.limit.unwrap_or(50);
let offset = query.offset.unwrap_or(0);
let mut q = products::table
.filter(products::org_id.eq(org_id))
.filter(products::bot_id.eq(bot_id))
.into_boxed();
if let Some(is_active) = query.is_active {
q = q.filter(products::is_active.eq(is_active));
}
if let Some(category) = query.category {
q = q.filter(products::category.eq(category));
}
if let Some(true) = query.low_stock {
q = q.filter(products::stock_quantity.le(products::low_stock_threshold));
}
if let Some(search) = query.search {
let pattern = format!("%{search}%");
q = q.filter(
products::name
.ilike(pattern.clone())
.or(products::sku.ilike(pattern.clone()))
.or(products::description.ilike(pattern)),
);
}
let prods: Vec<Product> = q
.order(products::name.asc())
.limit(limit)
.offset(offset)
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(prods))
}
pub async fn get_product(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<ProductWithVariants>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let product: Product = products::table
.filter(products::id.eq(id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Product not found".to_string()))?;
let variants: Vec<ProductVariant> = product_variants::table
.filter(product_variants::product_id.eq(id))
.order(product_variants::name.asc())
.load(&mut conn)
.unwrap_or_default();
Ok(Json(ProductWithVariants { product, variants }))
}
pub async fn update_product(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateProductRequest>,
) -> Result<Json<Product>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let now = Utc::now();
diesel::update(products::table.filter(products::id.eq(id)))
.set(products::updated_at.eq(now))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
if let Some(name) = req.name {
diesel::update(products::table.filter(products::id.eq(id)))
.set(products::name.eq(name))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(description) = req.description {
diesel::update(products::table.filter(products::id.eq(id)))
.set(products::description.eq(description))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(price) = req.price {
diesel::update(products::table.filter(products::id.eq(id)))
.set(products::price.eq(bd(price)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(stock_quantity) = req.stock_quantity {
diesel::update(products::table.filter(products::id.eq(id)))
.set(products::stock_quantity.eq(stock_quantity))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(is_active) = req.is_active {
diesel::update(products::table.filter(products::id.eq(id)))
.set(products::is_active.eq(is_active))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(category) = req.category {
diesel::update(products::table.filter(products::id.eq(id)))
.set(products::category.eq(category))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
let product: Product = products::table
.filter(products::id.eq(id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Product not found".to_string()))?;
Ok(Json(product))
}
pub async fn delete_product(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
diesel::delete(products::table.filter(products::id.eq(id)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn adjust_stock(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<AdjustStockRequest>,
) -> Result<Json<Product>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let now = Utc::now();
let product: Product = products::table
.filter(products::id.eq(id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Product not found".to_string()))?;
let new_quantity = match req.movement_type.as_str() {
"in" | "purchase" | "return" | "adjustment_add" => product.stock_quantity + req.quantity,
"out" | "sale" | "adjustment_remove" | "damage" => product.stock_quantity - req.quantity,
"set" => req.quantity,
_ => product.stock_quantity + req.quantity,
};
diesel::update(products::table.filter(products::id.eq(id)))
.set((
products::stock_quantity.eq(new_quantity),
products::updated_at.eq(now),
))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
let movement = InventoryMovement {
id: Uuid::new_v4(),
org_id,
bot_id,
product_id: id,
movement_type: req.movement_type,
quantity: req.quantity,
reference_type: req.reference_type,
reference_id: req.reference_id,
notes: req.notes,
created_by: None,
created_at: now,
};
diesel::insert_into(inventory_movements::table)
.values(&movement)
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
let updated: Product = products::table
.filter(products::id.eq(id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Product not found".to_string()))?;
Ok(Json(updated))
}
pub async fn create_service(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateServiceRequest>,
) -> Result<Json<Service>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let id = Uuid::new_v4();
let now = Utc::now();
let service = Service {
id,
org_id,
bot_id,
name: req.name,
description: req.description,
category: req.category,
service_type: req.service_type.unwrap_or_else(|| "hourly".to_string()),
hourly_rate: req.hourly_rate.map(bd),
fixed_price: req.fixed_price.map(bd),
currency: req.currency.unwrap_or_else(|| "USD".to_string()),
duration_minutes: req.duration_minutes,
is_active: true,
attributes: serde_json::json!({}),
created_at: now,
updated_at: now,
};
diesel::insert_into(services::table)
.values(&service)
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
Ok(Json(service))
}
pub async fn list_services(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<Service>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let limit = query.limit.unwrap_or(50);
let offset = query.offset.unwrap_or(0);
let mut q = services::table
.filter(services::org_id.eq(org_id))
.filter(services::bot_id.eq(bot_id))
.into_boxed();
if let Some(is_active) = query.is_active {
q = q.filter(services::is_active.eq(is_active));
}
if let Some(category) = query.category {
q = q.filter(services::category.eq(category));
}
if let Some(search) = query.search {
let pattern = format!("%{search}%");
q = q.filter(
services::name
.ilike(pattern.clone())
.or(services::description.ilike(pattern)),
);
}
let svcs: Vec<Service> = q
.order(services::name.asc())
.limit(limit)
.offset(offset)
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(svcs))
}
pub async fn get_service(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<Service>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let service: Service = services::table
.filter(services::id.eq(id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Service not found".to_string()))?;
Ok(Json(service))
}
pub async fn update_service(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateServiceRequest>,
) -> Result<Json<Service>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let now = Utc::now();
diesel::update(services::table.filter(services::id.eq(id)))
.set(services::updated_at.eq(now))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
if let Some(name) = req.name {
diesel::update(services::table.filter(services::id.eq(id)))
.set(services::name.eq(name))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(description) = req.description {
diesel::update(services::table.filter(services::id.eq(id)))
.set(services::description.eq(description))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(hourly_rate) = req.hourly_rate {
diesel::update(services::table.filter(services::id.eq(id)))
.set(services::hourly_rate.eq(Some(bd(hourly_rate))))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(is_active) = req.is_active {
diesel::update(services::table.filter(services::id.eq(id)))
.set(services::is_active.eq(is_active))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
let service: Service = services::table
.filter(services::id.eq(id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Service not found".to_string()))?;
Ok(Json(service))
}
pub async fn delete_service(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
diesel::delete(services::table.filter(services::id.eq(id)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_categories(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<ProductCategory>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let cats: Vec<ProductCategory> = product_categories::table
.filter(product_categories::org_id.eq(org_id))
.filter(product_categories::bot_id.eq(bot_id))
.filter(product_categories::is_active.eq(true))
.order(product_categories::sort_order.asc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(cats))
}
pub async fn create_category(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateCategoryRequest>,
) -> Result<Json<ProductCategory>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let id = Uuid::new_v4();
let now = Utc::now();
let max_order: Option<i32> = product_categories::table
.filter(product_categories::org_id.eq(org_id))
.filter(product_categories::bot_id.eq(bot_id))
.select(diesel::dsl::max(product_categories::sort_order))
.first(&mut conn)
.unwrap_or(None);
let category = ProductCategory {
id,
org_id,
bot_id,
name: req.name,
description: req.description,
parent_id: req.parent_id,
slug: req.slug,
image_url: req.image_url,
sort_order: max_order.unwrap_or(0) + 1,
is_active: true,
created_at: now,
};
diesel::insert_into(product_categories::table)
.values(&category)
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
Ok(Json(category))
}
pub async fn list_price_lists(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<PriceList>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let lists: Vec<PriceList> = price_lists::table
.filter(price_lists::org_id.eq(org_id))
.filter(price_lists::bot_id.eq(bot_id))
.filter(price_lists::is_active.eq(true))
.order(price_lists::name.asc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(lists))
}
pub async fn create_price_list(
State(state): State<Arc<AppState>>,
Json(req): Json<CreatePriceListRequest>,
) -> Result<Json<PriceList>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let id = Uuid::new_v4();
let now = Utc::now();
let valid_from = req
.valid_from
.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
let valid_until = req
.valid_until
.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
let price_list = PriceList {
id,
org_id,
bot_id,
name: req.name,
description: req.description,
currency: req.currency.unwrap_or_else(|| "USD".to_string()),
is_default: false,
valid_from,
valid_until,
customer_group: req.customer_group,
discount_percent: bd(req.discount_percent.unwrap_or(0.0)),
is_active: true,
created_at: now,
updated_at: now,
};
diesel::insert_into(price_lists::table)
.values(&price_list)
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
Ok(Json(price_list))
}
pub async fn list_inventory_movements(
State(state): State<Arc<AppState>>,
Path(product_id): Path<Uuid>,
) -> Result<Json<Vec<InventoryMovement>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let movements: Vec<InventoryMovement> = inventory_movements::table
.filter(inventory_movements::product_id.eq(product_id))
.order(inventory_movements::created_at.desc())
.limit(100)
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(movements))
}
pub async fn get_product_stats(
State(state): State<Arc<AppState>>,
) -> Result<Json<ProductStats>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let total_products: i64 = products::table
.filter(products::org_id.eq(org_id))
.filter(products::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let active_products: i64 = products::table
.filter(products::org_id.eq(org_id))
.filter(products::bot_id.eq(bot_id))
.filter(products::is_active.eq(true))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let total_services: i64 = services::table
.filter(services::org_id.eq(org_id))
.filter(services::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let active_services: i64 = services::table
.filter(services::org_id.eq(org_id))
.filter(services::bot_id.eq(bot_id))
.filter(services::is_active.eq(true))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let low_stock_count: i64 = products::table
.filter(products::org_id.eq(org_id))
.filter(products::bot_id.eq(bot_id))
.filter(products::is_active.eq(true))
.filter(products::stock_quantity.le(products::low_stock_threshold))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let categories_count: i64 = product_categories::table
.filter(product_categories::org_id.eq(org_id))
.filter(product_categories::bot_id.eq(bot_id))
.filter(product_categories::is_active.eq(true))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let price_lists_count: i64 = price_lists::table
.filter(price_lists::org_id.eq(org_id))
.filter(price_lists::bot_id.eq(bot_id))
.filter(price_lists::is_active.eq(true))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let all_products: Vec<Product> = products::table
.filter(products::org_id.eq(org_id))
.filter(products::bot_id.eq(bot_id))
.filter(products::is_active.eq(true))
.load(&mut conn)
.unwrap_or_default();
let total_stock_value: f64 = all_products
.iter()
.map(|p| bd_to_f64(&p.price) * p.stock_quantity as f64)
.sum();
let stats = ProductStats {
total_products,
active_products,
total_services,
active_services,
low_stock_count,
total_stock_value,
categories_count,
price_lists_count,
};
Ok(Json(stats))
}
pub async fn list_low_stock(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Product>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let prods: Vec<Product> = products::table
.filter(products::org_id.eq(org_id))
.filter(products::bot_id.eq(bot_id))
.filter(products::is_active.eq(true))
.filter(products::stock_quantity.le(products::low_stock_threshold))
.order(products::stock_quantity.asc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(prods))
}
pub fn configure_products_api_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/products/items", get(list_products).post(create_product))
.route("/api/products/items/:id", get(get_product).put(update_product).delete(delete_product))
.route("/api/products/items/:id/stock", put(adjust_stock))
.route("/api/products/items/:id/movements", get(list_inventory_movements))
.route("/api/products/services", get(list_services).post(create_service))
.route("/api/products/services/:id", get(get_service).put(update_service).delete(delete_service))
.route("/api/products/categories", get(list_categories).post(create_category))
.route("/api/products/price-lists", get(list_price_lists).post(create_price_list))
.route("/api/products/stats", get(get_product_stats))
.route("/api/products/low-stock", get(list_low_stock))
}

View file

@ -1,18 +1,26 @@
pub mod api;
use axum::{
extract::{Query, State},
response::{Html, IntoResponse},
routing::get,
Router,
};
use bigdecimal::{BigDecimal, ToPrimitive};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{products, services, price_lists};
use crate::shared::state::AppState;
#[derive(Debug, Deserialize)]
pub struct ProductQuery {
pub category: Option<String>,
pub status: Option<String>,
pub limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
@ -20,98 +28,477 @@ pub struct SearchQuery {
pub q: Option<String>,
}
fn bd_to_f64(bd: &BigDecimal) -> f64 {
bd.to_f64().unwrap_or(0.0)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn format_currency(amount: f64, currency: &str) -> String {
match currency.to_uppercase().as_str() {
"USD" => format!("${:.2}", amount),
"EUR" => format!("{:.2}", amount),
"GBP" => format!("£{:.2}", amount),
"BRL" => format!("R${:.2}", amount),
_ => format!("{:.2} {}", amount, currency),
}
}
pub fn configure_products_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/products/items", get(handle_products_items))
.route("/api/products/services", get(handle_products_services))
.route("/api/products/pricelists", get(handle_products_pricelists))
.route(
"/api/products/stats/total-products",
get(handle_total_products),
)
.route(
"/api/products/stats/total-services",
get(handle_total_services),
)
.route("/api/products/stats/total-products", get(handle_total_products))
.route("/api/products/stats/total-services", get(handle_total_services))
.route("/api/products/stats/pricelists", get(handle_total_pricelists))
.route("/api/products/stats/active", get(handle_active_products))
.route("/api/products/search", get(handle_products_search))
}
async fn handle_products_items(
State(_state): State<Arc<AppState>>,
Query(_query): Query<ProductQuery>,
State(state): State<Arc<AppState>>,
Query(query): Query<ProductQuery>,
) -> impl IntoResponse {
Html(
r#"<div class="products-empty">
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let mut db_query = products::table
.filter(products::bot_id.eq(bot_id))
.into_boxed();
if let Some(ref category) = query.category {
db_query = db_query.filter(products::category.eq(category));
}
if let Some(ref status) = query.status {
let is_active = status == "active";
db_query = db_query.filter(products::is_active.eq(is_active));
}
db_query = db_query.order(products::created_at.desc());
let limit = query.limit.unwrap_or(50);
db_query = db_query.limit(limit);
db_query
.select((
products::id,
products::sku,
products::name,
products::description,
products::category,
products::price,
products::currency,
products::stock_quantity,
products::is_active,
))
.load::<(Uuid, Option<String>, String, Option<String>, Option<String>, BigDecimal, String, i32, bool)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(items) if !items.is_empty() => {
let mut html = String::new();
for (id, sku, name, desc, category, price, currency, stock, is_active) in items {
let sku_str = sku.unwrap_or_else(|| "-".to_string());
let desc_str = desc.unwrap_or_default();
let cat_str = category.unwrap_or_else(|| "Uncategorized".to_string());
let price_str = format_currency(bd_to_f64(&price), &currency);
let stock_str = stock.to_string();
let status_class = if is_active { "status-active" } else { "status-inactive" };
let status_text = if is_active { "Active" } else { "Inactive" };
html.push_str(&format!(
r##"<div class="product-card" data-id="{id}">
<div class="product-header">
<span class="product-name">{}</span>
<span class="product-sku">{}</span>
</div>
<div class="product-body">
<p class="product-desc">{}</p>
<div class="product-meta">
<span class="product-category">{}</span>
<span class="product-price">{}</span>
<span class="product-stock">Stock: {}</span>
<span class="{}">{}</span>
</div>
</div>
<div class="product-actions">
<button class="btn-sm" hx-get="/api/products/{id}" hx-target="#product-detail">View</button>
<button class="btn-sm btn-secondary" hx-get="/api/products/{id}/edit" hx-target="#modal-content">Edit</button>
</div>
</div>"##,
html_escape(&name),
html_escape(&sku_str),
html_escape(&desc_str),
html_escape(&cat_str),
price_str,
stock_str,
status_class,
status_text
));
}
Html(format!(r##"<div class="products-grid">{html}</div>"##))
}
_ => Html(
r##"<div class="products-empty">
<div class="empty-icon">📦</div>
<p>No products yet</p>
<p class="empty-hint">Add your first product to get started</p>
</div>"#
.to_string(),
)
</div>"##.to_string(),
),
}
}
async fn handle_products_services(
State(_state): State<Arc<AppState>>,
Query(_query): Query<ProductQuery>,
State(state): State<Arc<AppState>>,
Query(query): Query<ProductQuery>,
) -> impl IntoResponse {
Html(
r#"<tr class="empty-row">
<td colspan="6" class="empty-state">
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let mut db_query = services::table
.filter(services::bot_id.eq(bot_id))
.into_boxed();
if let Some(ref category) = query.category {
db_query = db_query.filter(services::category.eq(category));
}
if let Some(ref status) = query.status {
let is_active = status == "active";
db_query = db_query.filter(services::is_active.eq(is_active));
}
db_query = db_query.order(services::created_at.desc());
let limit = query.limit.unwrap_or(50);
db_query = db_query.limit(limit);
db_query
.select((
services::id,
services::name,
services::description,
services::category,
services::service_type,
services::hourly_rate,
services::fixed_price,
services::currency,
services::duration_minutes,
services::is_active,
))
.load::<(Uuid, String, Option<String>, Option<String>, String, Option<BigDecimal>, Option<BigDecimal>, String, Option<i32>, bool)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(items) if !items.is_empty() => {
let mut html = String::new();
for (id, name, desc, category, svc_type, hourly, fixed, currency, duration, is_active) in items {
let desc_str = desc.unwrap_or_default();
let cat_str = category.unwrap_or_else(|| "General".to_string());
let type_str = svc_type;
let price_str = if let Some(ref h) = hourly {
format!("{}/hr", format_currency(bd_to_f64(h), &currency))
} else if let Some(ref f) = fixed {
format_currency(bd_to_f64(f), &currency)
} else {
"-".to_string()
};
let duration_str = duration.map(|d| format!("{} min", d)).unwrap_or_else(|| "-".to_string());
let status_class = if is_active { "status-active" } else { "status-inactive" };
let status_text = if is_active { "Active" } else { "Inactive" };
html.push_str(&format!(
r##"<tr class="service-row" data-id="{id}">
<td class="service-name">{}</td>
<td class="service-category">{}</td>
<td class="service-type">{}</td>
<td class="service-price">{}</td>
<td class="service-duration">{}</td>
<td class="service-status"><span class="{}">{}</span></td>
<td class="service-actions">
<button class="btn-sm" hx-get="/api/products/services/{id}" hx-target="#service-detail">View</button>
</td>
</tr>"##,
html_escape(&name),
html_escape(&cat_str),
html_escape(&type_str),
price_str,
duration_str,
status_class,
status_text
));
}
Html(html)
}
_ => Html(
r##"<tr class="empty-row">
<td colspan="7" class="empty-state">
<div class="empty-icon">🔧</div>
<p>No services yet</p>
<p class="empty-hint">Add services to your catalog</p>
</td>
</tr>"#
.to_string(),
)
</tr>"##.to_string(),
),
}
}
async fn handle_products_pricelists(
State(_state): State<Arc<AppState>>,
Query(_query): Query<ProductQuery>,
State(state): State<Arc<AppState>>,
Query(query): Query<ProductQuery>,
) -> impl IntoResponse {
Html(
r#"<tr class="empty-row">
<td colspan="5" class="empty-state">
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
let mut db_query = price_lists::table
.filter(price_lists::bot_id.eq(bot_id))
.into_boxed();
if let Some(ref status) = query.status {
let is_active = status == "active";
db_query = db_query.filter(price_lists::is_active.eq(is_active));
}
db_query = db_query.order(price_lists::created_at.desc());
let limit = query.limit.unwrap_or(50);
db_query = db_query.limit(limit);
db_query
.select((
price_lists::id,
price_lists::name,
price_lists::description,
price_lists::currency,
price_lists::is_default,
price_lists::discount_percent,
price_lists::customer_group,
price_lists::is_active,
))
.load::<(Uuid, String, Option<String>, String, bool, BigDecimal, Option<String>, bool)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(items) if !items.is_empty() => {
let mut html = String::new();
for (id, name, desc, currency, is_default, discount, customer_group, is_active) in items {
let discount_pct = bd_to_f64(&discount);
let discount_str = if discount_pct > 0.0 { format!("{:.1}%", discount_pct) } else { "-".to_string() };
let group_str = customer_group.unwrap_or_else(|| "All".to_string());
let default_badge = if is_default { r##"<span class="badge badge-primary">Default</span>"## } else { "" };
let status_class = if is_active { "status-active" } else { "status-inactive" };
let status_text = if is_active { "Active" } else { "Inactive" };
html.push_str(&format!(
r##"<tr class="pricelist-row" data-id="{id}">
<td class="pricelist-name">{} {}</td>
<td class="pricelist-currency">{}</td>
<td class="pricelist-discount">{}</td>
<td class="pricelist-group">{}</td>
<td class="pricelist-status"><span class="{}">{}</span></td>
<td class="pricelist-actions">
<button class="btn-sm" hx-get="/api/products/pricelists/{id}" hx-target="#pricelist-detail">View</button>
</td>
</tr>"##,
html_escape(&name),
default_badge,
currency,
discount_str,
html_escape(&group_str),
status_class,
status_text
));
}
Html(html)
}
_ => Html(
r##"<tr class="empty-row">
<td colspan="6" class="empty-state">
<div class="empty-icon">💰</div>
<p>No price lists yet</p>
<p class="empty-hint">Create price lists for different customer segments</p>
</td>
</tr>"#
.to_string(),
)
</tr>"##.to_string(),
),
}
}
async fn handle_total_products(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("0".to_string())
async fn handle_total_products(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
products::table
.filter(products::bot_id.eq(bot_id))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
async fn handle_total_services(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("0".to_string())
async fn handle_total_services(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
services::table
.filter(services::bot_id.eq(bot_id))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
async fn handle_total_pricelists(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("0".to_string())
async fn handle_total_pricelists(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
price_lists::table
.filter(price_lists::bot_id.eq(bot_id))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
async fn handle_active_products(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
Html("0".to_string())
async fn handle_active_products(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let pool = state.conn.clone();
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
products::table
.filter(products::bot_id.eq(bot_id))
.filter(products::is_active.eq(true))
.count()
.get_result::<i64>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
Html(format!("{}", result.unwrap_or(0)))
}
async fn handle_products_search(
State(_state): State<Arc<AppState>>,
State(state): State<Arc<AppState>>,
Query(query): Query<SearchQuery>,
) -> impl IntoResponse {
let q = query.q.unwrap_or_default();
let q = query.q.clone().unwrap_or_default();
if q.is_empty() {
return Html(String::new());
}
Html(format!(
r#"<div class="search-results-empty">
<p>No results for "{}"</p>
</div>"#,
q
let pool = state.conn.clone();
let search_term = format!("%{}%", q);
let result = tokio::task::spawn_blocking(move || {
let mut conn = pool.get().ok()?;
let (bot_id, _) = get_default_bot(&mut conn);
products::table
.filter(products::bot_id.eq(bot_id))
.filter(
products::name.ilike(&search_term)
.or(products::sku.ilike(&search_term))
.or(products::description.ilike(&search_term))
)
.order(products::name.asc())
.limit(20)
.select((
products::id,
products::sku,
products::name,
products::category,
products::price,
products::currency,
))
.load::<(Uuid, Option<String>, String, Option<String>, BigDecimal, String)>(&mut conn)
.ok()
})
.await
.ok()
.flatten();
match result {
Some(items) if !items.is_empty() => {
let mut html = String::new();
for (id, sku, name, category, price, currency) in items {
let sku_str = sku.unwrap_or_else(|| "-".to_string());
let cat_str = category.unwrap_or_else(|| "Uncategorized".to_string());
let price_str = format_currency(bd_to_f64(&price), &currency);
html.push_str(&format!(
r##"<div class="search-result-item" hx-get="/api/products/{id}" hx-target="#product-detail">
<span class="result-name">{}</span>
<span class="result-sku">{}</span>
<span class="result-category">{}</span>
<span class="result-price">{}</span>
</div>"##,
html_escape(&name),
html_escape(&sku_str),
html_escape(&cat_str),
price_str
));
}
Html(format!(r##"<div class="search-results">{html}</div>"##))
}
_ => Html(format!(
r##"<div class="search-results-empty">
<p>No results for "{}"</p>
</div>"##,
html_escape(&q)
)),
}
}

File diff suppressed because it is too large Load diff

897
src/tickets/mod.rs Normal file
View file

@ -0,0 +1,897 @@
pub mod ui;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, put},
Json, Router,
};
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{
support_tickets, ticket_canned_responses, ticket_categories, ticket_comments,
ticket_sla_policies, ticket_tags,
};
use crate::shared::state::AppState;
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = support_tickets)]
pub struct SupportTicket {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub ticket_number: String,
pub subject: String,
pub description: Option<String>,
pub status: String,
pub priority: String,
pub category: Option<String>,
pub source: String,
pub requester_id: Option<Uuid>,
pub requester_email: Option<String>,
pub requester_name: Option<String>,
pub assignee_id: Option<Uuid>,
pub team_id: Option<Uuid>,
pub due_date: Option<DateTime<Utc>>,
pub first_response_at: Option<DateTime<Utc>>,
pub resolved_at: Option<DateTime<Utc>>,
pub closed_at: Option<DateTime<Utc>>,
pub satisfaction_rating: Option<i32>,
pub tags: Vec<String>,
pub custom_fields: serde_json::Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
#[diesel(table_name = ticket_comments)]
pub struct TicketComment {
pub id: Uuid,
pub ticket_id: Uuid,
pub author_id: Option<Uuid>,
pub author_name: Option<String>,
pub author_email: Option<String>,
pub content: String,
pub is_internal: bool,
pub attachments: serde_json::Value,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = ticket_sla_policies)]
pub struct TicketSlaPolicy {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub name: String,
pub priority: String,
pub first_response_hours: i32,
pub resolution_hours: i32,
pub business_hours_only: bool,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = ticket_canned_responses)]
pub struct TicketCannedResponse {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub title: String,
pub content: String,
pub category: Option<String>,
pub shortcut: Option<String>,
pub created_by: Option<Uuid>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
#[diesel(table_name = ticket_categories)]
pub struct TicketCategory {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub name: String,
pub description: Option<String>,
pub parent_id: Option<Uuid>,
pub color: Option<String>,
pub icon: Option<String>,
pub sort_order: i32,
pub is_active: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Insertable)]
#[diesel(table_name = ticket_tags)]
pub struct TicketTag {
pub id: Uuid,
pub org_id: Uuid,
pub bot_id: Uuid,
pub name: String,
pub color: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateTicketRequest {
pub subject: String,
pub description: Option<String>,
pub priority: Option<String>,
pub category: Option<String>,
pub source: Option<String>,
pub requester_email: Option<String>,
pub requester_name: Option<String>,
pub assignee_id: Option<Uuid>,
pub tags: Option<Vec<String>>,
pub due_date: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTicketRequest {
pub subject: Option<String>,
pub description: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
pub category: Option<String>,
pub assignee_id: Option<Uuid>,
pub tags: Option<Vec<String>>,
pub due_date: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AssignTicketRequest {
pub assignee_id: Uuid,
}
#[derive(Debug, Deserialize)]
pub struct ChangeStatusRequest {
pub status: String,
pub resolution: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCommentRequest {
pub content: String,
pub is_internal: Option<bool>,
pub author_name: Option<String>,
pub author_email: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCannedResponseRequest {
pub title: String,
pub content: String,
pub category: Option<String>,
pub shortcut: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCategoryRequest {
pub name: String,
pub description: Option<String>,
pub parent_id: Option<Uuid>,
pub color: Option<String>,
pub icon: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub search: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
pub category: Option<String>,
pub assignee_id: Option<Uuid>,
pub requester_id: Option<Uuid>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct TicketStats {
pub total_tickets: i64,
pub open_tickets: i64,
pub pending_tickets: i64,
pub resolved_tickets: i64,
pub closed_tickets: i64,
pub avg_resolution_hours: f64,
pub overdue_tickets: i64,
}
#[derive(Debug, Serialize)]
pub struct TicketWithComments {
pub ticket: SupportTicket,
pub comments: Vec<TicketComment>,
}
fn get_bot_context(state: &AppState) -> (Uuid, Uuid) {
let Ok(mut conn) = state.conn.get() else {
return (Uuid::nil(), Uuid::nil());
};
let (bot_id, _bot_name) = get_default_bot(&mut conn);
let org_id = Uuid::nil();
(org_id, bot_id)
}
fn generate_ticket_number(conn: &mut diesel::PgConnection, org_id: Uuid) -> String {
let count: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.count()
.get_result(conn)
.unwrap_or(0);
format!("TKT-{:06}", count + 1)
}
pub async fn create_ticket(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateTicketRequest>,
) -> Result<Json<SupportTicket>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let id = Uuid::new_v4();
let now = Utc::now();
let ticket_number = generate_ticket_number(&mut conn, org_id);
let due_date = req
.due_date
.and_then(|d| DateTime::parse_from_rfc3339(&d).ok())
.map(|d| d.with_timezone(&Utc));
let ticket = SupportTicket {
id,
org_id,
bot_id,
ticket_number,
subject: req.subject,
description: req.description,
status: "open".to_string(),
priority: req.priority.unwrap_or_else(|| "medium".to_string()),
category: req.category,
source: req.source.unwrap_or_else(|| "web".to_string()),
requester_id: None,
requester_email: req.requester_email,
requester_name: req.requester_name,
assignee_id: req.assignee_id,
team_id: None,
due_date,
first_response_at: None,
resolved_at: None,
closed_at: None,
satisfaction_rating: None,
tags: req.tags.unwrap_or_default(),
custom_fields: serde_json::json!({}),
created_at: now,
updated_at: now,
};
diesel::insert_into(support_tickets::table)
.values(&ticket)
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
Ok(Json(ticket))
}
pub async fn list_tickets(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<SupportTicket>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let limit = query.limit.unwrap_or(50);
let offset = query.offset.unwrap_or(0);
let mut q = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.into_boxed();
if let Some(status) = query.status {
q = q.filter(support_tickets::status.eq(status));
}
if let Some(priority) = query.priority {
q = q.filter(support_tickets::priority.eq(priority));
}
if let Some(category) = query.category {
q = q.filter(support_tickets::category.eq(category));
}
if let Some(assignee_id) = query.assignee_id {
q = q.filter(support_tickets::assignee_id.eq(assignee_id));
}
if let Some(search) = query.search {
let pattern = format!("%{search}%");
q = q.filter(
support_tickets::subject
.ilike(pattern.clone())
.or(support_tickets::description.ilike(pattern.clone()))
.or(support_tickets::ticket_number.ilike(pattern)),
);
}
let tickets: Vec<SupportTicket> = q
.order(support_tickets::created_at.desc())
.limit(limit)
.offset(offset)
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(tickets))
}
pub async fn get_ticket(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<SupportTicket>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let ticket: SupportTicket = support_tickets::table
.filter(support_tickets::id.eq(id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Ticket not found".to_string()))?;
Ok(Json(ticket))
}
pub async fn get_ticket_with_comments(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<TicketWithComments>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let ticket: SupportTicket = support_tickets::table
.filter(support_tickets::id.eq(id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Ticket not found".to_string()))?;
let comments: Vec<TicketComment> = ticket_comments::table
.filter(ticket_comments::ticket_id.eq(id))
.order(ticket_comments::created_at.asc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(TicketWithComments { ticket, comments }))
}
pub async fn update_ticket(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateTicketRequest>,
) -> Result<Json<SupportTicket>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let now = Utc::now();
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set(support_tickets::updated_at.eq(now))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
if let Some(subject) = req.subject {
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set(support_tickets::subject.eq(subject))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(description) = req.description {
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set(support_tickets::description.eq(description))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(priority) = req.priority {
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set(support_tickets::priority.eq(priority))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(category) = req.category {
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set(support_tickets::category.eq(category))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(assignee_id) = req.assignee_id {
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set(support_tickets::assignee_id.eq(Some(assignee_id)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if let Some(tags) = req.tags {
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set(support_tickets::tags.eq(tags))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
get_ticket(State(state), Path(id)).await
}
pub async fn assign_ticket(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<AssignTicketRequest>,
) -> Result<Json<SupportTicket>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let now = Utc::now();
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set((
support_tickets::assignee_id.eq(Some(req.assignee_id)),
support_tickets::updated_at.eq(now),
))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
get_ticket(State(state), Path(id)).await
}
pub async fn change_status(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
Json(req): Json<ChangeStatusRequest>,
) -> Result<Json<SupportTicket>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let now = Utc::now();
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set((
support_tickets::status.eq(&req.status),
support_tickets::updated_at.eq(now),
))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
if req.status == "resolved" {
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set(support_tickets::resolved_at.eq(Some(now)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
if req.status == "closed" {
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set(support_tickets::closed_at.eq(Some(now)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
get_ticket(State(state), Path(id)).await
}
pub async fn resolve_ticket(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<SupportTicket>, (StatusCode, String)> {
change_status(
State(state),
Path(id),
Json(ChangeStatusRequest {
status: "resolved".to_string(),
resolution: None,
}),
)
.await
}
pub async fn close_ticket(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<SupportTicket>, (StatusCode, String)> {
change_status(
State(state),
Path(id),
Json(ChangeStatusRequest {
status: "closed".to_string(),
resolution: None,
}),
)
.await
}
pub async fn reopen_ticket(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<Json<SupportTicket>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let now = Utc::now();
diesel::update(support_tickets::table.filter(support_tickets::id.eq(id)))
.set((
support_tickets::status.eq("open"),
support_tickets::resolved_at.eq(None::<DateTime<Utc>>),
support_tickets::closed_at.eq(None::<DateTime<Utc>>),
support_tickets::updated_at.eq(now),
))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
get_ticket(State(state), Path(id)).await
}
pub async fn delete_ticket(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
diesel::delete(support_tickets::table.filter(support_tickets::id.eq(id)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Delete error: {e}")))?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn add_comment(
State(state): State<Arc<AppState>>,
Path(ticket_id): Path<Uuid>,
Json(req): Json<CreateCommentRequest>,
) -> Result<Json<TicketComment>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let id = Uuid::new_v4();
let now = Utc::now();
let comment = TicketComment {
id,
ticket_id,
author_id: None,
author_name: req.author_name,
author_email: req.author_email,
content: req.content,
is_internal: req.is_internal.unwrap_or(false),
attachments: serde_json::json!([]),
created_at: now,
};
diesel::insert_into(ticket_comments::table)
.values(&comment)
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
let ticket: SupportTicket = support_tickets::table
.filter(support_tickets::id.eq(ticket_id))
.first(&mut conn)
.map_err(|_| (StatusCode::NOT_FOUND, "Ticket not found".to_string()))?;
if ticket.first_response_at.is_none() && !comment.is_internal {
diesel::update(support_tickets::table.filter(support_tickets::id.eq(ticket_id)))
.set(support_tickets::first_response_at.eq(Some(now)))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
}
diesel::update(support_tickets::table.filter(support_tickets::id.eq(ticket_id)))
.set(support_tickets::updated_at.eq(now))
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Update error: {e}")))?;
Ok(Json(comment))
}
pub async fn list_comments(
State(state): State<Arc<AppState>>,
Path(ticket_id): Path<Uuid>,
) -> Result<Json<Vec<TicketComment>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let comments: Vec<TicketComment> = ticket_comments::table
.filter(ticket_comments::ticket_id.eq(ticket_id))
.order(ticket_comments::created_at.asc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(comments))
}
pub async fn get_ticket_stats(
State(state): State<Arc<AppState>>,
) -> Result<Json<TicketStats>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let total_tickets: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let open_tickets: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.eq("open"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let pending_tickets: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.eq("pending"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let resolved_tickets: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.eq("resolved"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let closed_tickets: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.eq("closed"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let now = Utc::now();
let overdue_tickets: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.ne("closed"))
.filter(support_tickets::status.ne("resolved"))
.filter(support_tickets::due_date.lt(now))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let stats = TicketStats {
total_tickets,
open_tickets,
pending_tickets,
resolved_tickets,
closed_tickets,
avg_resolution_hours: 0.0,
overdue_tickets,
};
Ok(Json(stats))
}
pub async fn list_overdue_tickets(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<SupportTicket>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let now = Utc::now();
let tickets: Vec<SupportTicket> = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.ne("closed"))
.filter(support_tickets::status.ne("resolved"))
.filter(support_tickets::due_date.lt(now))
.order(support_tickets::due_date.asc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(tickets))
}
pub async fn list_canned_responses(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<TicketCannedResponse>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let responses: Vec<TicketCannedResponse> = ticket_canned_responses::table
.filter(ticket_canned_responses::org_id.eq(org_id))
.filter(ticket_canned_responses::bot_id.eq(bot_id))
.filter(ticket_canned_responses::is_active.eq(true))
.order(ticket_canned_responses::title.asc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(responses))
}
pub async fn create_canned_response(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateCannedResponseRequest>,
) -> Result<Json<TicketCannedResponse>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let id = Uuid::new_v4();
let now = Utc::now();
let response = TicketCannedResponse {
id,
org_id,
bot_id,
title: req.title,
content: req.content,
category: req.category,
shortcut: req.shortcut,
created_by: None,
is_active: true,
created_at: now,
updated_at: now,
};
diesel::insert_into(ticket_canned_responses::table)
.values(&response)
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
Ok(Json(response))
}
pub async fn list_categories(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<TicketCategory>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let categories: Vec<TicketCategory> = ticket_categories::table
.filter(ticket_categories::org_id.eq(org_id))
.filter(ticket_categories::bot_id.eq(bot_id))
.filter(ticket_categories::is_active.eq(true))
.order(ticket_categories::sort_order.asc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(categories))
}
pub async fn create_category(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateCategoryRequest>,
) -> Result<Json<TicketCategory>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let id = Uuid::new_v4();
let now = Utc::now();
let max_order: Option<i32> = ticket_categories::table
.filter(ticket_categories::org_id.eq(org_id))
.filter(ticket_categories::bot_id.eq(bot_id))
.select(diesel::dsl::max(ticket_categories::sort_order))
.first(&mut conn)
.unwrap_or(None);
let category = TicketCategory {
id,
org_id,
bot_id,
name: req.name,
description: req.description,
parent_id: req.parent_id,
color: req.color,
icon: req.icon,
sort_order: max_order.unwrap_or(0) + 1,
is_active: true,
created_at: now,
};
diesel::insert_into(ticket_categories::table)
.values(&category)
.execute(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Insert error: {e}")))?;
Ok(Json(category))
}
pub async fn list_sla_policies(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<TicketSlaPolicy>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let policies: Vec<TicketSlaPolicy> = ticket_sla_policies::table
.filter(ticket_sla_policies::org_id.eq(org_id))
.filter(ticket_sla_policies::bot_id.eq(bot_id))
.filter(ticket_sla_policies::is_active.eq(true))
.order(ticket_sla_policies::priority.asc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(policies))
}
pub async fn list_tags(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<TicketTag>>, (StatusCode, String)> {
let mut conn = state.conn.get().map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}"))
})?;
let (org_id, bot_id) = get_bot_context(&state);
let tags: Vec<TicketTag> = ticket_tags::table
.filter(ticket_tags::org_id.eq(org_id))
.filter(ticket_tags::bot_id.eq(bot_id))
.order(ticket_tags::name.asc())
.load(&mut conn)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Query error: {e}")))?;
Ok(Json(tags))
}
pub fn configure_tickets_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/tickets", get(list_tickets).post(create_ticket))
.route("/api/tickets/stats", get(get_ticket_stats))
.route("/api/tickets/overdue", get(list_overdue_tickets))
.route("/api/tickets/:id", get(get_ticket).put(update_ticket).delete(delete_ticket))
.route("/api/tickets/:id/full", get(get_ticket_with_comments))
.route("/api/tickets/:id/assign", put(assign_ticket))
.route("/api/tickets/:id/status", put(change_status))
.route("/api/tickets/:id/resolve", put(resolve_ticket))
.route("/api/tickets/:id/close", put(close_ticket))
.route("/api/tickets/:id/reopen", put(reopen_ticket))
.route("/api/tickets/:id/comments", get(list_comments).post(add_comment))
.route("/api/tickets/canned", get(list_canned_responses).post(create_canned_response))
.route("/api/tickets/categories", get(list_categories).post(create_category))
.route("/api/tickets/sla", get(list_sla_policies))
.route("/api/tickets/tags", get(list_tags))
}

753
src/tickets/ui.rs Normal file
View file

@ -0,0 +1,753 @@
use axum::{
extract::{Path, Query, State},
response::{Html, IntoResponse},
routing::get,
Router,
};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{support_tickets, ticket_comments};
use crate::shared::state::AppState;
use crate::tickets::{SupportTicket, TicketComment};
#[derive(Debug, Deserialize)]
pub struct StatusQuery {
pub status: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SearchQuery {
pub q: Option<String>,
}
fn get_bot_context(state: &AppState) -> (Uuid, Uuid) {
let Ok(mut conn) = state.conn.get() else {
return (Uuid::nil(), Uuid::nil());
};
let (bot_id, _bot_name) = get_default_bot(&mut conn);
let org_id = Uuid::nil();
(org_id, bot_id)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
fn priority_badge(priority: &str) -> &'static str {
match priority {
"urgent" => "<span class=\"badge badge-danger\">Urgent</span>",
"high" => "<span class=\"badge badge-warning\">High</span>",
"medium" => "<span class=\"badge badge-info\">Medium</span>",
"low" => "<span class=\"badge badge-secondary\">Low</span>",
_ => "<span class=\"badge\">Unknown</span>",
}
}
fn status_badge(status: &str) -> &'static str {
match status {
"open" => "<span class=\"badge badge-primary\">Open</span>",
"pending" => "<span class=\"badge badge-warning\">Pending</span>",
"resolved" => "<span class=\"badge badge-success\">Resolved</span>",
"closed" => "<span class=\"badge badge-secondary\">Closed</span>",
_ => "<span class=\"badge\">Unknown</span>",
}
}
fn render_empty_state(icon: &str, title: &str, description: &str) -> String {
format!(
"<div class=\"empty-state\">\
<div class=\"empty-icon\">{}</div>\
<h3>{}</h3>\
<p>{}</p>\
</div>",
icon, title, description
)
}
fn render_ticket_row(ticket: &SupportTicket) -> String {
let requester = ticket
.requester_name
.as_deref()
.or(ticket.requester_email.as_deref())
.unwrap_or("Unknown");
let assignee = ticket
.assignee_id
.map(|_| "Assigned")
.unwrap_or("Unassigned");
let created = ticket.created_at.format("%Y-%m-%d %H:%M").to_string();
let hash = "#";
format!(
"<tr class=\"ticket-row\" data-id=\"{id}\">\
<td class=\"ticket-number\">{number}</td>\
<td class=\"ticket-subject\">\
<a href=\"{hash}\" hx-get=\"/api/ui/tickets/{id}\" hx-target=\"{hash}ticket-detail\" hx-swap=\"innerHTML\">{subject}</a>\
</td>\
<td class=\"ticket-requester\">{requester}</td>\
<td class=\"ticket-status\">{status}</td>\
<td class=\"ticket-priority\">{priority}</td>\
<td class=\"ticket-assignee\">{assignee}</td>\
<td class=\"ticket-created\">{created}</td>\
<td class=\"ticket-actions\">\
<button class=\"btn-icon\" hx-put=\"/api/tickets/{id}/resolve\" hx-swap=\"none\" title=\"Resolve\">✓</button>\
<button class=\"btn-icon\" hx-delete=\"/api/tickets/{id}\" hx-confirm=\"Delete this ticket?\" hx-swap=\"none\" title=\"Delete\">×</button>\
</td>\
</tr>",
id = ticket.id,
hash = hash,
number = html_escape(&ticket.ticket_number),
subject = html_escape(&ticket.subject),
requester = html_escape(requester),
status = status_badge(&ticket.status),
priority = priority_badge(&ticket.priority),
assignee = html_escape(assignee),
created = created,
)
}
fn render_ticket_card(ticket: &SupportTicket) -> String {
let requester = ticket
.requester_name
.as_deref()
.or(ticket.requester_email.as_deref())
.unwrap_or("Unknown");
let hash = "#";
format!(
"<div class=\"ticket-card\" data-id=\"{id}\">\
<div class=\"ticket-card-header\">\
<span class=\"ticket-number\">{number}</span>\
{status}\
{priority}\
</div>\
<div class=\"ticket-card-body\">\
<h4 class=\"ticket-subject\">{subject}</h4>\
<p class=\"ticket-requester\">From: {requester}</p>\
</div>\
<div class=\"ticket-card-footer\">\
<button class=\"btn-sm\" hx-get=\"/api/ui/tickets/{id}\" hx-target=\"{hash}ticket-detail\">View</button>\
<button class=\"btn-sm btn-success\" hx-put=\"/api/tickets/{id}/resolve\" hx-swap=\"none\">Resolve</button>\
</div>\
</div>",
id = ticket.id,
hash = hash,
number = html_escape(&ticket.ticket_number),
subject = html_escape(&ticket.subject),
requester = html_escape(requester),
status = status_badge(&ticket.status),
priority = priority_badge(&ticket.priority),
)
}
pub fn configure_tickets_ui_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/ui/tickets", get(handle_tickets_list))
.route("/api/ui/tickets/count", get(handle_tickets_count))
.route("/api/ui/tickets/open-count", get(handle_open_count))
.route("/api/ui/tickets/overdue-count", get(handle_overdue_count))
.route("/api/ui/tickets/cards", get(handle_tickets_cards))
.route("/api/ui/tickets/search", get(handle_tickets_search))
.route("/api/ui/tickets/:id", get(handle_ticket_detail))
.route("/api/ui/tickets/:id/comments", get(handle_ticket_comments))
.route("/api/ui/tickets/stats/by-status", get(handle_stats_by_status))
.route("/api/ui/tickets/stats/by-priority", get(handle_stats_by_priority))
.route("/api/ui/tickets/stats/avg-resolution", get(handle_avg_resolution))
}
async fn handle_tickets_list(
State(state): State<Arc<AppState>>,
Query(query): Query<StatusQuery>,
) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("🎫", "No tickets", "Unable to load tickets"));
};
let (org_id, bot_id) = get_bot_context(&state);
let mut q = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.into_boxed();
if let Some(status) = query.status {
if status != "all" {
q = q.filter(support_tickets::status.eq(status));
}
}
let tickets: Vec<SupportTicket> = q
.order(support_tickets::created_at.desc())
.limit(50)
.load(&mut conn)
.unwrap_or_default();
if tickets.is_empty() {
return Html(render_empty_state(
"🎫",
"No tickets yet",
"Create your first ticket to get started",
));
}
let mut html = String::from(
"<table class=\"tickets-table\">\
<thead>\
<tr>\
<th>Number</th>\
<th>Subject</th>\
<th>Requester</th>\
<th>Status</th>\
<th>Priority</th>\
<th>Assignee</th>\
<th>Created</th>\
<th>Actions</th>\
</tr>\
</thead>\
<tbody>",
);
for ticket in &tickets {
html.push_str(&render_ticket_row(ticket));
}
html.push_str("</tbody></table>");
Html(html)
}
async fn handle_tickets_count(
State(state): State<Arc<AppState>>,
Query(query): Query<StatusQuery>,
) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("0".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let count: i64 = if let Some(status) = query.status {
if status == "all" {
support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0)
} else {
support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.eq(status))
.count()
.get_result(&mut conn)
.unwrap_or(0)
}
} else {
support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0)
};
Html(count.to_string())
}
async fn handle_open_count(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("0".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let count: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.eq("open"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Html(count.to_string())
}
async fn handle_overdue_count(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("0".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let now = chrono::Utc::now();
let count: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.ne("closed"))
.filter(support_tickets::status.ne("resolved"))
.filter(support_tickets::due_date.lt(now))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Html(count.to_string())
}
async fn handle_tickets_cards(
State(state): State<Arc<AppState>>,
Query(query): Query<StatusQuery>,
) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("🎫", "No tickets", "Unable to load tickets"));
};
let (org_id, bot_id) = get_bot_context(&state);
let mut q = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.into_boxed();
if let Some(status) = query.status {
if status != "all" {
q = q.filter(support_tickets::status.eq(status));
}
}
let tickets: Vec<SupportTicket> = q
.order(support_tickets::created_at.desc())
.limit(20)
.load(&mut conn)
.unwrap_or_default();
if tickets.is_empty() {
return Html(render_empty_state(
"🎫",
"No tickets",
"No tickets match your criteria",
));
}
let mut html = String::from("<div class=\"tickets-grid\">");
for ticket in &tickets {
html.push_str(&render_ticket_card(ticket));
}
html.push_str("</div>");
Html(html)
}
async fn handle_tickets_search(
State(state): State<Arc<AppState>>,
Query(query): Query<SearchQuery>,
) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("🔍", "Search error", "Unable to search tickets"));
};
let (org_id, bot_id) = get_bot_context(&state);
let search_term = query.q.unwrap_or_default();
if search_term.is_empty() {
return Html(render_empty_state(
"🔍",
"Enter search term",
"Type to search tickets",
));
}
let pattern = format!("%{search_term}%");
let tickets: Vec<SupportTicket> = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(
support_tickets::subject
.ilike(pattern.clone())
.or(support_tickets::description.ilike(pattern.clone()))
.or(support_tickets::ticket_number.ilike(pattern)),
)
.order(support_tickets::created_at.desc())
.limit(20)
.load(&mut conn)
.unwrap_or_default();
if tickets.is_empty() {
return Html(render_empty_state(
"🔍",
"No results",
"No tickets match your search",
));
}
let mut html = String::from("<div class=\"search-results\">");
for ticket in &tickets {
html.push_str(&render_ticket_card(ticket));
}
html.push_str("</div>");
Html(html)
}
async fn handle_ticket_detail(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("", "Error", "Unable to load ticket"));
};
let ticket: Result<SupportTicket, _> = support_tickets::table
.filter(support_tickets::id.eq(id))
.first(&mut conn);
let Ok(ticket) = ticket else {
return Html(render_empty_state("", "Not found", "Ticket not found"));
};
let comments: Vec<TicketComment> = ticket_comments::table
.filter(ticket_comments::ticket_id.eq(id))
.order(ticket_comments::created_at.asc())
.load(&mut conn)
.unwrap_or_default();
let requester = ticket
.requester_name
.as_deref()
.or(ticket.requester_email.as_deref())
.unwrap_or("Unknown");
let description = ticket
.description
.as_deref()
.unwrap_or("No description provided");
let created = ticket.created_at.format("%Y-%m-%d %H:%M:%S").to_string();
let updated = ticket.updated_at.format("%Y-%m-%d %H:%M:%S").to_string();
let category = ticket.category.as_deref().unwrap_or("-");
let mut comments_html = String::new();
for comment in &comments {
let author = comment
.author_name
.as_deref()
.or(comment.author_email.as_deref())
.unwrap_or("Unknown");
let comment_time = comment.created_at.format("%Y-%m-%d %H:%M").to_string();
let internal_class = if comment.is_internal { " internal" } else { "" };
let internal_badge = if comment.is_internal {
"<span class=\"badge badge-warning\">Internal</span>"
} else {
""
};
comments_html.push_str(&format!(
"<div class=\"comment{}\">\
<div class=\"comment-header\">\
<span class=\"comment-author\">{}</span>\
<span class=\"comment-time\">{}</span>\
{}\
</div>\
<div class=\"comment-body\">{}</div>\
</div>",
internal_class,
html_escape(author),
comment_time,
internal_badge,
html_escape(&comment.content),
));
}
let html = format!(
"<div class=\"ticket-detail\">\
<div class=\"ticket-detail-header\">\
<h2>{}: {}</h2>\
<div class=\"ticket-badges\">\
{}\
{}\
</div>\
</div>\
<div class=\"ticket-detail-meta\">\
<div class=\"meta-item\">\
<label>Requester</label>\
<span>{}</span>\
</div>\
<div class=\"meta-item\">\
<label>Created</label>\
<span>{}</span>\
</div>\
<div class=\"meta-item\">\
<label>Updated</label>\
<span>{}</span>\
</div>\
<div class=\"meta-item\">\
<label>Category</label>\
<span>{}</span>\
</div>\
</div>\
<div class=\"ticket-detail-description\">\
<h3>Description</h3>\
<p>{}</p>\
</div>\
<div class=\"ticket-detail-actions\">\
<button class=\"btn btn-success\" hx-put=\"/api/tickets/{}/resolve\" hx-swap=\"none\">Resolve</button>\
<button class=\"btn btn-secondary\" hx-put=\"/api/tickets/{}/close\" hx-swap=\"none\">Close</button>\
<button class=\"btn btn-warning\" hx-put=\"/api/tickets/{}/reopen\" hx-swap=\"none\">Reopen</button>\
</div>\
<div class=\"ticket-comments\">\
<h3>Comments ({})</h3>\
{}\
<form class=\"comment-form\" hx-post=\"/api/tickets/{}/comments\" hx-target=\"#ticket-detail\" hx-swap=\"innerHTML\">\
<textarea name=\"content\" placeholder=\"Add a comment...\" required></textarea>\
<div class=\"comment-form-actions\">\
<label>\
<input type=\"checkbox\" name=\"is_internal\" value=\"true\">\
Internal note\
</label>\
<button type=\"submit\" class=\"btn btn-primary\">Add Comment</button>\
</div>\
</form>\
</div>\
</div>",
html_escape(&ticket.ticket_number),
html_escape(&ticket.subject),
status_badge(&ticket.status),
priority_badge(&ticket.priority),
html_escape(requester),
created,
updated,
html_escape(category),
html_escape(description),
ticket.id,
ticket.id,
ticket.id,
comments.len(),
comments_html,
ticket.id,
);
Html(html)
}
async fn handle_ticket_comments(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("<p>Unable to load comments</p>".to_string());
};
let comments: Vec<TicketComment> = ticket_comments::table
.filter(ticket_comments::ticket_id.eq(id))
.order(ticket_comments::created_at.asc())
.load(&mut conn)
.unwrap_or_default();
if comments.is_empty() {
return Html("<p class=\"no-comments\">No comments yet</p>".to_string());
}
let mut html = String::new();
for comment in &comments {
let author = comment
.author_name
.as_deref()
.or(comment.author_email.as_deref())
.unwrap_or("Unknown");
let comment_time = comment.created_at.format("%Y-%m-%d %H:%M").to_string();
let internal_class = if comment.is_internal { " internal" } else { "" };
html.push_str(&format!(
"<div class=\"comment{}\">\
<div class=\"comment-header\">\
<span class=\"comment-author\">{}</span>\
<span class=\"comment-time\">{}</span>\
</div>\
<div class=\"comment-body\">{}</div>\
</div>",
internal_class,
html_escape(author),
comment_time,
html_escape(&comment.content),
));
}
Html(html)
}
async fn handle_stats_by_status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("<p>Unable to load stats</p>".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let open: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.eq("open"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let pending: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.eq("pending"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let resolved: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.eq("resolved"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let closed: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::status.eq("closed"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let html = format!(
"<div class=\"stats-grid\">\
<div class=\"stat-card stat-open\">\
<div class=\"stat-value\">{}</div>\
<div class=\"stat-label\">Open</div>\
</div>\
<div class=\"stat-card stat-pending\">\
<div class=\"stat-value\">{}</div>\
<div class=\"stat-label\">Pending</div>\
</div>\
<div class=\"stat-card stat-resolved\">\
<div class=\"stat-value\">{}</div>\
<div class=\"stat-label\">Resolved</div>\
</div>\
<div class=\"stat-card stat-closed\">\
<div class=\"stat-value\">{}</div>\
<div class=\"stat-label\">Closed</div>\
</div>\
</div>",
open, pending, resolved, closed
);
Html(html)
}
async fn handle_stats_by_priority(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("<p>Unable to load stats</p>".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let urgent: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::priority.eq("urgent"))
.filter(support_tickets::status.ne("closed"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let high: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::priority.eq("high"))
.filter(support_tickets::status.ne("closed"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let medium: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::priority.eq("medium"))
.filter(support_tickets::status.ne("closed"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let low: i64 = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::priority.eq("low"))
.filter(support_tickets::status.ne("closed"))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let html = format!(
"<div class=\"priority-stats\">\
<div class=\"priority-bar\">\
<div class=\"priority-segment urgent\" style=\"flex: {};\" title=\"Urgent: {}\"></div>\
<div class=\"priority-segment high\" style=\"flex: {};\" title=\"High: {}\"></div>\
<div class=\"priority-segment medium\" style=\"flex: {};\" title=\"Medium: {}\"></div>\
<div class=\"priority-segment low\" style=\"flex: {};\" title=\"Low: {}\"></div>\
</div>\
<div class=\"priority-legend\">\
<span class=\"legend-item\"><span class=\"dot urgent\"></span>Urgent ({})</span>\
<span class=\"legend-item\"><span class=\"dot high\"></span>High ({})</span>\
<span class=\"legend-item\"><span class=\"dot medium\"></span>Medium ({})</span>\
<span class=\"legend-item\"><span class=\"dot low\"></span>Low ({})</span>\
</div>\
</div>",
urgent, urgent, high, high, medium, medium, low, low,
urgent, high, medium, low
);
Html(html)
}
async fn handle_avg_resolution(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let Ok(mut conn) = state.conn.get() else {
return Html("-".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let resolved_tickets: Vec<SupportTicket> = support_tickets::table
.filter(support_tickets::org_id.eq(org_id))
.filter(support_tickets::bot_id.eq(bot_id))
.filter(support_tickets::resolved_at.is_not_null())
.limit(100)
.load(&mut conn)
.unwrap_or_default();
if resolved_tickets.is_empty() {
return Html("-".to_string());
}
let total_hours: f64 = resolved_tickets
.iter()
.filter_map(|t| {
t.resolved_at.map(|resolved| {
let duration = resolved - t.created_at;
duration.num_hours() as f64
})
})
.sum();
let avg_hours = total_hours / resolved_tickets.len() as f64;
if avg_hours < 24.0 {
Html(format!("{:.1}h", avg_hours))
} else {
Html(format!("{:.1}d", avg_hours / 24.0))
}
}

View file

@ -3,10 +3,8 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::{
Block, BlockContent, BlockProperties, BlockType, CalloutContent, ChecklistContent,
ChecklistItem, CodeContent, EmbedContent, EmbedType, GbComponentContent, GbComponentType,
MediaContent, RichText, TableCell, TableContent, TableRow, TextAnnotations, TextSegment,
ToggleContent, WorkspaceIcon,
Block, BlockContent, BlockProperties, BlockType, ChecklistItem, RichText, TableCell, TableRow,
TextAnnotations, TextSegment,
};
pub struct BlockBuilder {
@ -29,19 +27,21 @@ impl BlockBuilder {
}
pub fn with_text(mut self, text: &str) -> Self {
self.content = BlockContent::Text(RichText {
self.content = BlockContent::Text {
text: RichText {
segments: vec![TextSegment {
text: text.to_string(),
annotations: TextAnnotations::default(),
link: None,
mention: None,
}],
});
},
};
self
}
pub fn with_rich_text(mut self, rich_text: RichText) -> Self {
self.content = BlockContent::Text(rich_text);
self.content = BlockContent::Text { text: rich_text };
self
}
@ -55,7 +55,7 @@ impl BlockBuilder {
self
}
pub fn with_indent(mut self, level: u8) -> Self {
pub fn with_indent(mut self, level: u32) -> Self {
self.properties.indent_level = level;
self
}
@ -144,9 +144,9 @@ pub fn create_checklist(items: Vec<(&str, bool)>, created_by: Uuid) -> Block {
Block {
id: Uuid::new_v4(),
block_type: BlockType::Checklist,
content: BlockContent::Checklist(ChecklistContent {
content: BlockContent::Checklist {
items: checklist_items,
}),
},
properties: BlockProperties::default(),
children: Vec::new(),
created_at: now,
@ -155,12 +155,12 @@ pub fn create_checklist(items: Vec<(&str, bool)>, created_by: Uuid) -> Block {
}
}
pub fn create_toggle(title: &str, expanded: bool, children: Vec<Block>, created_by: Uuid) -> Block {
pub fn create_toggle(title: &str, expanded: bool, created_by: Uuid) -> Block {
let now = Utc::now();
Block {
id: Uuid::new_v4(),
block_type: BlockType::Toggle,
content: BlockContent::Toggle(ToggleContent {
content: BlockContent::Toggle {
title: RichText {
segments: vec![TextSegment {
text: title.to_string(),
@ -170,9 +170,9 @@ pub fn create_toggle(title: &str, expanded: bool, children: Vec<Block>, created_
}],
},
expanded,
}),
},
properties: BlockProperties::default(),
children,
children: Vec::new(),
created_at: now,
updated_at: now,
created_by,
@ -190,11 +190,8 @@ pub fn create_callout(icon: &str, text: &str, background: &str, created_by: Uuid
Block {
id: Uuid::new_v4(),
block_type: BlockType::Callout,
content: BlockContent::Callout(CalloutContent {
icon: WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: icon.to_string(),
},
content: BlockContent::Callout {
icon: Some(icon.to_string()),
text: RichText {
segments: vec![TextSegment {
text: text.to_string(),
@ -203,9 +200,11 @@ pub fn create_callout(icon: &str, text: &str, background: &str, created_by: Uuid
mention: None,
}],
},
background_color: background.to_string(),
}),
properties: BlockProperties::default(),
},
properties: BlockProperties {
background_color: Some(background.to_string()),
..Default::default()
},
children: Vec::new(),
created_at: now,
updated_at: now,
@ -232,43 +231,10 @@ pub fn create_code(code: &str, language: &str, created_by: Uuid) -> Block {
Block {
id: Uuid::new_v4(),
block_type: BlockType::Code,
content: BlockContent::Code(CodeContent {
content: BlockContent::Code {
code: code.to_string(),
language: language.to_string(),
caption: None,
wrap: false,
}),
properties: BlockProperties::default(),
children: Vec::new(),
created_at: now,
updated_at: now,
created_by,
}
}
pub fn create_table(rows: usize, cols: usize, has_header: bool, created_by: Uuid) -> Block {
let table_rows: Vec<TableRow> = (0..rows)
.map(|_| TableRow {
id: Uuid::new_v4(),
cells: (0..cols)
.map(|_| TableCell {
content: RichText { segments: Vec::new() },
background_color: None,
})
.collect(),
})
.collect();
let now = Utc::now();
Block {
id: Uuid::new_v4(),
block_type: BlockType::Table,
content: BlockContent::Table(TableContent {
rows: table_rows,
has_header_row: has_header,
has_header_column: false,
column_widths: vec![200; cols],
}),
language: Some(language.to_string()),
},
properties: BlockProperties::default(),
children: Vec::new(),
created_at: now,
@ -282,20 +248,10 @@ pub fn create_image(url: &str, caption: Option<&str>, created_by: Uuid) -> Block
Block {
id: Uuid::new_v4(),
block_type: BlockType::Image,
content: BlockContent::Media(MediaContent {
content: BlockContent::Media {
url: url.to_string(),
caption: caption.map(|c| RichText {
segments: vec![TextSegment {
text: c.to_string(),
annotations: TextAnnotations::default(),
link: None,
mention: None,
}],
}),
alt_text: None,
width: None,
height: None,
}),
caption: caption.map(|s| s.to_string()),
},
properties: BlockProperties::default(),
children: Vec::new(),
created_at: now,
@ -304,16 +260,58 @@ pub fn create_image(url: &str, caption: Option<&str>, created_by: Uuid) -> Block
}
}
pub fn create_embed(url: &str, embed_type: EmbedType, created_by: Uuid) -> Block {
pub fn create_video(url: &str, caption: Option<&str>, created_by: Uuid) -> Block {
let now = Utc::now();
Block {
id: Uuid::new_v4(),
block_type: BlockType::Video,
content: BlockContent::Media {
url: url.to_string(),
caption: caption.map(|s| s.to_string()),
},
properties: BlockProperties::default(),
children: Vec::new(),
created_at: now,
updated_at: now,
created_by,
}
}
pub fn create_embed(url: &str, embed_type: &str, created_by: Uuid) -> Block {
let now = Utc::now();
Block {
id: Uuid::new_v4(),
block_type: BlockType::Embed,
content: BlockContent::Embed(EmbedContent {
content: BlockContent::Embed {
url: url.to_string(),
embed_type,
caption: None,
}),
embed_type: Some(embed_type.to_string()),
},
properties: BlockProperties::default(),
children: Vec::new(),
created_at: now,
updated_at: now,
created_by,
}
}
pub fn create_table(rows: usize, cols: usize, created_by: Uuid) -> Block {
let now = Utc::now();
let table_rows: Vec<TableRow> = (0..rows)
.map(|_| TableRow {
id: Uuid::new_v4(),
cells: (0..cols)
.map(|_| TableCell {
content: RichText { segments: vec![] },
background_color: None,
})
.collect(),
})
.collect();
Block {
id: Uuid::new_v4(),
block_type: BlockType::Table,
content: BlockContent::Table { rows: table_rows },
properties: BlockProperties::default(),
children: Vec::new(),
created_at: now,
@ -323,19 +321,18 @@ pub fn create_embed(url: &str, embed_type: EmbedType, created_by: Uuid) -> Block
}
pub fn create_gb_component(
component_type: GbComponentType,
bot_id: Option<Uuid>,
component_type: &str,
config: serde_json::Value,
created_by: Uuid,
) -> Block {
let now = Utc::now();
Block {
id: Uuid::new_v4(),
block_type: BlockType::GbComponent,
content: BlockContent::GbComponent(GbComponentContent {
component_type,
bot_id,
config: std::collections::HashMap::new(),
}),
content: BlockContent::GbComponent {
component_type: component_type.to_string(),
config,
},
properties: BlockProperties::default(),
children: Vec::new(),
created_at: now,
@ -347,9 +344,9 @@ pub fn create_gb_component(
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockOperation {
pub operation_type: BlockOperationType,
pub block_id: Uuid,
pub block_id: Option<Uuid>,
pub parent_id: Option<Uuid>,
pub index: Option<usize>,
pub position: Option<usize>,
pub block: Option<Block>,
pub properties: Option<BlockProperties>,
pub content: Option<BlockContent>,
@ -362,181 +359,98 @@ pub enum BlockOperationType {
Update,
Delete,
Move,
UpdateProperties,
UpdateContent,
Duplicate,
}
pub fn apply_block_operation(blocks: &mut Vec<Block>, operation: BlockOperation) -> Result<(), String> {
match operation.operation_type {
pub fn apply_block_operations(blocks: &mut Vec<Block>, operations: Vec<BlockOperation>) {
for op in operations {
match op.operation_type {
BlockOperationType::Insert => {
let block = operation.block.ok_or("Block required for insert")?;
let index = operation.index.unwrap_or(blocks.len());
if index > blocks.len() {
blocks.push(block);
} else {
blocks.insert(index, block);
if let Some(block) = op.block {
let position = op.position.unwrap_or(blocks.len());
if position <= blocks.len() {
blocks.insert(position, block);
}
}
}
BlockOperationType::Update => {
let new_block = operation.block.ok_or("Block required for update")?;
if let Some(block) = blocks.iter_mut().find(|b| b.id == operation.block_id) {
*block = new_block;
} else {
return Err("Block not found".to_string());
if let Some(block_id) = op.block_id {
if let Some(block) = find_block_mut(blocks, block_id) {
if let Some(content) = op.content {
block.content = content;
}
if let Some(props) = op.properties {
block.properties = props;
}
block.updated_at = Utc::now();
}
}
}
BlockOperationType::Delete => {
blocks.retain(|b| b.id != operation.block_id);
if let Some(block_id) = op.block_id {
remove_block(blocks, block_id);
}
}
BlockOperationType::Move => {
let index = operation.index.ok_or("Index required for move")?;
if let Some(pos) = blocks.iter().position(|b| b.id == operation.block_id) {
let block = blocks.remove(pos);
let new_index = if index > pos { index - 1 } else { index };
if new_index >= blocks.len() {
blocks.push(block);
} else {
blocks.insert(new_index, block);
}
} else {
return Err("Block not found".to_string());
}
}
BlockOperationType::UpdateProperties => {
let props = operation.properties.ok_or("Properties required")?;
if let Some(block) = blocks.iter_mut().find(|b| b.id == operation.block_id) {
block.properties = props;
block.updated_at = Utc::now();
} else {
return Err("Block not found".to_string());
}
}
BlockOperationType::UpdateContent => {
let content = operation.content.ok_or("Content required")?;
if let Some(block) = blocks.iter_mut().find(|b| b.id == operation.block_id) {
block.content = content;
block.updated_at = Utc::now();
} else {
return Err("Block not found".to_string());
if let Some(block_id) = op.block_id {
if let Some(position) = op.position {
if let Some(block) = remove_block(blocks, block_id) {
let insert_pos = position.min(blocks.len());
blocks.insert(insert_pos, block);
}
}
}
}
BlockOperationType::Duplicate => {
if let Some(block_id) = op.block_id {
if let Some(block) = find_block(blocks, block_id) {
let mut new_block = block.clone();
new_block.id = Uuid::new_v4();
new_block.created_at = Utc::now();
new_block.updated_at = Utc::now();
let position = op.position.unwrap_or(blocks.len());
blocks.insert(position.min(blocks.len()), new_block);
}
}
}
}
}
Ok(())
}
pub fn blocks_to_plain_text(blocks: &[Block]) -> String {
let mut result = String::new();
for block in blocks {
if let BlockContent::Text(rich_text) = &block.content {
for segment in &rich_text.segments {
result.push_str(&segment.text);
}
result.push('\n');
}
if !block.children.is_empty() {
result.push_str(&blocks_to_plain_text(&block.children));
}
}
result
}
pub fn blocks_to_markdown(blocks: &[Block], indent: usize) -> String {
let mut result = String::new();
let prefix = " ".repeat(indent);
for block in blocks {
match block.block_type {
BlockType::Heading1 => {
if let BlockContent::Text(rt) = &block.content {
result.push_str(&format!("{}# {}\n\n", prefix, rich_text_to_string(rt)));
}
}
BlockType::Heading2 => {
if let BlockContent::Text(rt) = &block.content {
result.push_str(&format!("{}## {}\n\n", prefix, rich_text_to_string(rt)));
}
}
BlockType::Heading3 => {
if let BlockContent::Text(rt) = &block.content {
result.push_str(&format!("{}### {}\n\n", prefix, rich_text_to_string(rt)));
}
}
BlockType::Paragraph => {
if let BlockContent::Text(rt) = &block.content {
result.push_str(&format!("{}{}\n\n", prefix, rich_text_to_string(rt)));
}
}
BlockType::BulletedList => {
if let BlockContent::Text(rt) = &block.content {
result.push_str(&format!("{}- {}\n", prefix, rich_text_to_string(rt)));
}
}
BlockType::NumberedList => {
if let BlockContent::Text(rt) = &block.content {
result.push_str(&format!("{}1. {}\n", prefix, rich_text_to_string(rt)));
}
}
BlockType::Quote => {
if let BlockContent::Text(rt) = &block.content {
result.push_str(&format!("{}> {}\n\n", prefix, rich_text_to_string(rt)));
}
}
BlockType::Code => {
if let BlockContent::Code(code) = &block.content {
result.push_str(&format!("{}```{}\n{}\n{}```\n\n", prefix, code.language, code.code, prefix));
}
}
BlockType::Divider => {
result.push_str(&format!("{}---\n\n", prefix));
}
BlockType::Checklist => {
if let BlockContent::Checklist(cl) = &block.content {
for item in &cl.items {
let checkbox = if item.checked { "[x]" } else { "[ ]" };
result.push_str(&format!("{}- {} {}\n", prefix, checkbox, rich_text_to_string(&item.text)));
}
result.push('\n');
}
}
_ => {
if let BlockContent::Text(rt) = &block.content {
result.push_str(&format!("{}{}\n", prefix, rich_text_to_string(rt)));
}
}
}
if !block.children.is_empty() {
result.push_str(&blocks_to_markdown(&block.children, indent + 1));
}
}
result
}
fn rich_text_to_string(rich_text: &RichText) -> String {
rich_text.segments.iter().map(|s| s.text.as_str()).collect()
}
pub fn find_block_by_id(blocks: &[Block], block_id: Uuid) -> Option<&Block> {
fn find_block(blocks: &[Block], block_id: Uuid) -> Option<&Block> {
for block in blocks {
if block.id == block_id {
return Some(block);
}
if let Some(found) = find_block_by_id(&block.children, block_id) {
if let Some(found) = find_block(&block.children, block_id) {
return Some(found);
}
}
None
}
pub fn find_block_by_id_mut(blocks: &mut [Block], block_id: Uuid) -> Option<&mut Block> {
for block in blocks {
fn find_block_mut(blocks: &mut [Block], block_id: Uuid) -> Option<&mut Block> {
for block in blocks.iter_mut() {
if block.id == block_id {
return Some(block);
}
if let Some(found) = find_block_by_id_mut(&mut block.children, block_id) {
if let Some(found) = find_block_mut(&mut block.children, block_id) {
return Some(found);
}
}
None
}
fn remove_block(blocks: &mut Vec<Block>, block_id: Uuid) -> Option<Block> {
if let Some(pos) = blocks.iter().position(|b| b.id == block_id) {
return Some(blocks.remove(pos));
}
for block in blocks.iter_mut() {
if let Some(removed) = remove_block(&mut block.children, block_id) {
return Some(removed);
}
}
None
}

File diff suppressed because it is too large Load diff

View file

@ -404,7 +404,7 @@ fn count_blocks_stats(blocks: &[Block], stats: &mut PageStats) {
_ => {}
}
if let BlockContent::Text(rich_text) = &block.content {
if let BlockContent::Text { text: rich_text } = &block.content {
for segment in &rich_text.segments {
stats.total_characters += segment.text.len();
stats.total_words += segment.text.split_whitespace().count();

View file

@ -1,14 +1,9 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
use super::{
Block, Page, PagePermissions, Workspace, WorkspaceIcon, WorkspaceSettings,
blocks::{create_heading1, create_heading2, create_paragraph, create_checklist, create_callout, create_divider},
};
use super::{Block, Page, PagePermissions, WorkspaceIcon, WorkspaceSettings};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageTemplate {
@ -101,703 +96,6 @@ pub struct PageStructure {
pub children: Vec<PageStructure>,
}
pub struct TemplateService {
page_templates: Arc<RwLock<HashMap<Uuid, PageTemplate>>>,
workspace_templates: Arc<RwLock<HashMap<Uuid, WorkspaceTemplate>>>,
}
impl TemplateService {
pub fn new() -> Self {
let service = Self {
page_templates: Arc::new(RwLock::new(HashMap::new())),
workspace_templates: Arc::new(RwLock::new(HashMap::new())),
};
tokio::spawn({
let page_templates = service.page_templates.clone();
let workspace_templates = service.workspace_templates.clone();
async move {
let system_user = Uuid::nil();
let templates = create_system_page_templates(system_user);
let mut pt = page_templates.write().await;
for template in templates {
pt.insert(template.id, template);
}
let ws_templates = create_system_workspace_templates(system_user);
let mut wt = workspace_templates.write().await;
for template in ws_templates {
wt.insert(template.id, template);
}
}
});
service
}
pub async fn create_page_template(
&self,
name: &str,
description: &str,
blocks: Vec<Block>,
category: TemplateCategory,
created_by: Uuid,
organization_id: Option<Uuid>,
workspace_id: Option<Uuid>,
) -> PageTemplate {
let now = Utc::now();
let template = PageTemplate {
id: Uuid::new_v4(),
name: name.to_string(),
description: description.to_string(),
icon: None,
cover_image: None,
blocks,
properties: HashMap::new(),
category,
tags: Vec::new(),
is_system: false,
organization_id,
workspace_id,
created_by,
created_at: now,
updated_at: now,
use_count: 0,
};
let mut templates = self.page_templates.write().await;
templates.insert(template.id, template.clone());
template
}
pub async fn get_page_template(&self, template_id: Uuid) -> Option<PageTemplate> {
let templates = self.page_templates.read().await;
templates.get(&template_id).cloned()
}
pub async fn list_page_templates(
&self,
organization_id: Option<Uuid>,
workspace_id: Option<Uuid>,
category: Option<TemplateCategory>,
) -> Vec<PageTemplate> {
let templates = self.page_templates.read().await;
templates
.values()
.filter(|t| {
let org_match = t.is_system
|| t.organization_id.is_none()
|| t.organization_id == organization_id;
let ws_match = t.workspace_id.is_none() || t.workspace_id == workspace_id;
let cat_match = category.as_ref().map(|c| &t.category == c).unwrap_or(true);
org_match && ws_match && cat_match
})
.cloned()
.collect()
}
pub async fn update_page_template(
&self,
template_id: Uuid,
name: Option<String>,
description: Option<String>,
blocks: Option<Vec<Block>>,
icon: Option<WorkspaceIcon>,
) -> Result<PageTemplate, TemplateError> {
let mut templates = self.page_templates.write().await;
let template = templates
.get_mut(&template_id)
.ok_or(TemplateError::TemplateNotFound)?;
if template.is_system {
return Err(TemplateError::CannotModifySystemTemplate);
}
if let Some(n) = name {
template.name = n;
}
if let Some(d) = description {
template.description = d;
}
if let Some(b) = blocks {
template.blocks = b;
}
if icon.is_some() {
template.icon = icon;
}
template.updated_at = Utc::now();
Ok(template.clone())
}
pub async fn delete_page_template(&self, template_id: Uuid) -> Result<(), TemplateError> {
let mut templates = self.page_templates.write().await;
if let Some(template) = templates.get(&template_id) {
if template.is_system {
return Err(TemplateError::CannotModifySystemTemplate);
}
}
templates
.remove(&template_id)
.ok_or(TemplateError::TemplateNotFound)?;
Ok(())
}
pub async fn increment_template_usage(&self, template_id: Uuid) {
let mut templates = self.page_templates.write().await;
if let Some(template) = templates.get_mut(&template_id) {
template.use_count += 1;
}
}
pub async fn apply_page_template(
&self,
template_id: Uuid,
workspace_id: Uuid,
parent_id: Option<Uuid>,
title: Option<String>,
created_by: Uuid,
) -> Result<Page, TemplateError> {
let template = self
.get_page_template(template_id)
.await
.ok_or(TemplateError::TemplateNotFound)?;
self.increment_template_usage(template_id).await;
let now = Utc::now();
let page = Page {
id: Uuid::new_v4(),
workspace_id,
parent_id,
title: title.unwrap_or_else(|| template.name.clone()),
icon: template.icon.clone(),
cover_image: template.cover_image.clone(),
blocks: clone_blocks_with_new_ids(&template.blocks, created_by),
children: Vec::new(),
properties: HashMap::new(),
permissions: PagePermissions::default(),
is_template: false,
template_id: Some(template_id),
created_at: now,
updated_at: now,
created_by,
last_edited_by: created_by,
};
Ok(page)
}
pub async fn create_workspace_template(
&self,
name: &str,
description: &str,
settings: WorkspaceSettings,
category: TemplateCategory,
created_by: Uuid,
) -> WorkspaceTemplate {
let now = Utc::now();
let template = WorkspaceTemplate {
id: Uuid::new_v4(),
name: name.to_string(),
description: description.to_string(),
icon: None,
cover_image: None,
settings,
page_templates: Vec::new(),
default_structure: Vec::new(),
category,
is_system: false,
created_by,
created_at: now,
updated_at: now,
};
let mut templates = self.workspace_templates.write().await;
templates.insert(template.id, template.clone());
template
}
pub async fn get_workspace_template(&self, template_id: Uuid) -> Option<WorkspaceTemplate> {
let templates = self.workspace_templates.read().await;
templates.get(&template_id).cloned()
}
pub async fn list_workspace_templates(
&self,
category: Option<TemplateCategory>,
) -> Vec<WorkspaceTemplate> {
let templates = self.workspace_templates.read().await;
templates
.values()
.filter(|t| category.as_ref().map(|c| &t.category == c).unwrap_or(true))
.cloned()
.collect()
}
pub async fn apply_workspace_template(
&self,
template_id: Uuid,
organization_id: Uuid,
name: &str,
created_by: Uuid,
) -> Result<(Workspace, Vec<Page>), TemplateError> {
let template = self
.get_workspace_template(template_id)
.await
.ok_or(TemplateError::TemplateNotFound)?;
let now = Utc::now();
let workspace = Workspace {
id: Uuid::new_v4(),
organization_id,
name: name.to_string(),
description: Some(template.description.clone()),
icon: template.icon.clone(),
cover_image: template.cover_image.clone(),
members: vec![super::WorkspaceMember {
user_id: created_by,
role: super::WorkspaceRole::Owner,
joined_at: now,
invited_by: None,
}],
settings: template.settings.clone(),
root_pages: Vec::new(),
created_at: now,
updated_at: now,
created_by,
};
let pages = self
.create_pages_from_structure(
&template.default_structure,
workspace.id,
None,
created_by,
)
.await;
Ok((workspace, pages))
}
async fn create_pages_from_structure(
&self,
structure: &[PageStructure],
workspace_id: Uuid,
parent_id: Option<Uuid>,
created_by: Uuid,
) -> Vec<Page> {
let mut pages = Vec::new();
for item in structure {
let page = if let Some(template_id) = item.template_id {
self.apply_page_template(
template_id,
workspace_id,
parent_id,
Some(item.title.clone()),
created_by,
)
.await
.ok()
} else {
let now = Utc::now();
Some(Page {
id: Uuid::new_v4(),
workspace_id,
parent_id,
title: item.title.clone(),
icon: item.icon.clone(),
cover_image: None,
blocks: Vec::new(),
children: Vec::new(),
properties: HashMap::new(),
permissions: PagePermissions::default(),
is_template: false,
template_id: None,
created_at: now,
updated_at: now,
created_by,
last_edited_by: created_by,
})
};
if let Some(page) = page {
let page_id = page.id;
pages.push(page);
if !item.children.is_empty() {
let child_pages = Box::pin(self.create_pages_from_structure(
&item.children,
workspace_id,
Some(page_id),
created_by,
))
.await;
pages.extend(child_pages);
}
}
}
pages
}
}
impl Default for TemplateService {
fn default() -> Self {
Self::new()
}
}
fn clone_blocks_with_new_ids(blocks: &[Block], created_by: Uuid) -> Vec<Block> {
let now = Utc::now();
blocks
.iter()
.map(|block| Block {
id: Uuid::new_v4(),
block_type: block.block_type.clone(),
content: block.content.clone(),
properties: block.properties.clone(),
children: clone_blocks_with_new_ids(&block.children, created_by),
created_at: now,
updated_at: now,
created_by,
})
.collect()
}
fn create_system_page_templates(system_user: Uuid) -> Vec<PageTemplate> {
let now = Utc::now();
vec![
PageTemplate {
id: Uuid::new_v4(),
name: "Meeting Notes".to_string(),
description: "Template for capturing meeting notes with agenda, attendees, and action items".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "📝".to_string(),
}),
cover_image: None,
blocks: vec![
create_heading2("Attendees", system_user),
create_paragraph("@mention attendees here", system_user),
create_divider(system_user),
create_heading2("Agenda", system_user),
create_checklist(vec![
("Topic 1", false),
("Topic 2", false),
("Topic 3", false),
], system_user),
create_divider(system_user),
create_heading2("Discussion Notes", system_user),
create_paragraph("", system_user),
create_divider(system_user),
create_heading2("Action Items", system_user),
create_checklist(vec![
("Action item 1", false),
("Action item 2", false),
], system_user),
],
properties: HashMap::new(),
category: TemplateCategory::Meeting,
tags: vec!["meeting".to_string(), "notes".to_string()],
is_system: true,
organization_id: None,
workspace_id: None,
created_by: system_user,
created_at: now,
updated_at: now,
use_count: 0,
},
PageTemplate {
id: Uuid::new_v4(),
name: "Project Overview".to_string(),
description: "Template for project documentation with goals, timeline, and team".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "🚀".to_string(),
}),
cover_image: None,
blocks: vec![
create_callout("🎯", "Project Goal: Define your project goal here", "#E3F2FD", system_user),
create_divider(system_user),
create_heading2("Overview", system_user),
create_paragraph("Describe the project and its objectives.", system_user),
create_divider(system_user),
create_heading2("Team", system_user),
create_paragraph("@mention team members and their roles", system_user),
create_divider(system_user),
create_heading2("Timeline", system_user),
create_paragraph("Key milestones and deadlines", system_user),
create_divider(system_user),
create_heading2("Resources", system_user),
create_paragraph("Links to relevant documents and tools", system_user),
],
properties: HashMap::new(),
category: TemplateCategory::Project,
tags: vec!["project".to_string(), "planning".to_string()],
is_system: true,
organization_id: None,
workspace_id: None,
created_by: system_user,
created_at: now,
updated_at: now,
use_count: 0,
},
PageTemplate {
id: Uuid::new_v4(),
name: "Technical Specification".to_string(),
description: "Template for technical documentation and specifications".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "📋".to_string(),
}),
cover_image: None,
blocks: vec![
create_heading1("Technical Specification", system_user),
create_callout("📌", "Status: Draft", "#FFF3E0", system_user),
create_divider(system_user),
create_heading2("Summary", system_user),
create_paragraph("Brief overview of the technical solution.", system_user),
create_divider(system_user),
create_heading2("Requirements", system_user),
create_paragraph("List functional and non-functional requirements.", system_user),
create_divider(system_user),
create_heading2("Architecture", system_user),
create_paragraph("Describe the system architecture.", system_user),
create_divider(system_user),
create_heading2("Implementation Details", system_user),
create_paragraph("Technical implementation details.", system_user),
create_divider(system_user),
create_heading2("Testing Strategy", system_user),
create_paragraph("How the solution will be tested.", system_user),
create_divider(system_user),
create_heading2("Open Questions", system_user),
create_checklist(vec![
("Question 1?", false),
("Question 2?", false),
], system_user),
],
properties: HashMap::new(),
category: TemplateCategory::Engineering,
tags: vec!["technical".to_string(), "spec".to_string(), "engineering".to_string()],
is_system: true,
organization_id: None,
workspace_id: None,
created_by: system_user,
created_at: now,
updated_at: now,
use_count: 0,
},
PageTemplate {
id: Uuid::new_v4(),
name: "Weekly Status Update".to_string(),
description: "Template for weekly team status updates".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "📊".to_string(),
}),
cover_image: None,
blocks: vec![
create_heading2("Completed This Week", system_user),
create_checklist(vec![
("Task 1", true),
("Task 2", true),
], system_user),
create_divider(system_user),
create_heading2("In Progress", system_user),
create_checklist(vec![
("Task 3", false),
("Task 4", false),
], system_user),
create_divider(system_user),
create_heading2("Planned for Next Week", system_user),
create_checklist(vec![
("Task 5", false),
("Task 6", false),
], system_user),
create_divider(system_user),
create_heading2("Blockers", system_user),
create_callout("⚠️", "List any blockers or issues", "#FFEBEE", system_user),
],
properties: HashMap::new(),
category: TemplateCategory::Team,
tags: vec!["status".to_string(), "weekly".to_string(), "update".to_string()],
is_system: true,
organization_id: None,
workspace_id: None,
created_by: system_user,
created_at: now,
updated_at: now,
use_count: 0,
},
PageTemplate {
id: Uuid::new_v4(),
name: "Blank Page".to_string(),
description: "Start with a blank page".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "📄".to_string(),
}),
cover_image: None,
blocks: vec![
create_paragraph("", system_user),
],
properties: HashMap::new(),
category: TemplateCategory::Personal,
tags: vec!["blank".to_string(), "empty".to_string()],
is_system: true,
organization_id: None,
workspace_id: None,
created_by: system_user,
created_at: now,
updated_at: now,
use_count: 0,
},
]
}
fn create_system_workspace_templates(system_user: Uuid) -> Vec<WorkspaceTemplate> {
let now = Utc::now();
vec![
WorkspaceTemplate {
id: Uuid::new_v4(),
name: "Team Workspace".to_string(),
description: "A workspace for team collaboration with common sections".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "👥".to_string(),
}),
cover_image: None,
settings: WorkspaceSettings::default(),
page_templates: Vec::new(),
default_structure: vec![
PageStructure {
title: "Welcome".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "👋".to_string(),
}),
template_id: None,
children: Vec::new(),
},
PageStructure {
title: "Meetings".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "📅".to_string(),
}),
template_id: None,
children: Vec::new(),
},
PageStructure {
title: "Projects".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "📁".to_string(),
}),
template_id: None,
children: Vec::new(),
},
PageStructure {
title: "Documentation".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "📚".to_string(),
}),
template_id: None,
children: Vec::new(),
},
],
category: TemplateCategory::Team,
is_system: true,
created_by: system_user,
created_at: now,
updated_at: now,
},
WorkspaceTemplate {
id: Uuid::new_v4(),
name: "Project Workspace".to_string(),
description: "A workspace organized for project management".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "🎯".to_string(),
}),
cover_image: None,
settings: WorkspaceSettings::default(),
page_templates: Vec::new(),
default_structure: vec![
PageStructure {
title: "Project Overview".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "🚀".to_string(),
}),
template_id: None,
children: Vec::new(),
},
PageStructure {
title: "Requirements".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "📋".to_string(),
}),
template_id: None,
children: Vec::new(),
},
PageStructure {
title: "Design".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "🎨".to_string(),
}),
template_id: None,
children: Vec::new(),
},
PageStructure {
title: "Development".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "💻".to_string(),
}),
template_id: None,
children: Vec::new(),
},
PageStructure {
title: "Testing".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "🧪".to_string(),
}),
template_id: None,
children: Vec::new(),
},
PageStructure {
title: "Launch".to_string(),
icon: Some(WorkspaceIcon {
icon_type: super::IconType::Emoji,
value: "🎉".to_string(),
}),
template_id: None,
children: Vec::new(),
},
],
category: TemplateCategory::Project,
is_system: true,
created_by: system_user,
created_at: now,
updated_at: now,
},
]
}
#[derive(Debug, Clone)]
pub enum TemplateError {
TemplateNotFound,
@ -816,3 +114,109 @@ impl std::fmt::Display for TemplateError {
}
impl std::error::Error for TemplateError {}
pub fn clone_blocks_with_new_ids(blocks: &[Block], created_by: Uuid) -> Vec<Block> {
let now = Utc::now();
blocks
.iter()
.map(|block| {
let mut new_block = block.clone();
new_block.id = Uuid::new_v4();
new_block.created_at = now;
new_block.updated_at = now;
new_block.created_by = created_by;
new_block.children = clone_blocks_with_new_ids(&block.children, created_by);
new_block
})
.collect()
}
pub fn apply_template_to_page(
template: &PageTemplate,
workspace_id: Uuid,
parent_id: Option<Uuid>,
title: Option<String>,
created_by: Uuid,
) -> Page {
let now = Utc::now();
Page {
id: Uuid::new_v4(),
workspace_id,
parent_id,
title: title.unwrap_or_else(|| template.name.clone()),
icon: template.icon.clone(),
cover_image: template.cover_image.clone(),
blocks: clone_blocks_with_new_ids(&template.blocks, created_by),
children: Vec::new(),
properties: HashMap::new(),
permissions: PagePermissions::default(),
is_template: false,
template_id: Some(template.id),
created_at: now,
updated_at: now,
created_by,
last_edited_by: created_by,
}
}
pub fn get_system_templates() -> Vec<PageTemplate> {
let system_user = Uuid::nil();
let now = Utc::now();
vec![
PageTemplate {
id: Uuid::new_v4(),
name: "Meeting Notes".to_string(),
description: "Template for meeting notes with agenda and action items".to_string(),
icon: None,
cover_image: None,
blocks: vec![],
properties: HashMap::new(),
category: TemplateCategory::Meeting,
tags: vec!["meeting".to_string(), "notes".to_string()],
is_system: true,
organization_id: None,
workspace_id: None,
created_by: system_user,
created_at: now,
updated_at: now,
use_count: 0,
},
PageTemplate {
id: Uuid::new_v4(),
name: "Project Brief".to_string(),
description: "Template for project briefs and planning".to_string(),
icon: None,
cover_image: None,
blocks: vec![],
properties: HashMap::new(),
category: TemplateCategory::Project,
tags: vec!["project".to_string(), "planning".to_string()],
is_system: true,
organization_id: None,
workspace_id: None,
created_by: system_user,
created_at: now,
updated_at: now,
use_count: 0,
},
PageTemplate {
id: Uuid::new_v4(),
name: "Documentation".to_string(),
description: "Template for technical documentation".to_string(),
icon: None,
cover_image: None,
blocks: vec![],
properties: HashMap::new(),
category: TemplateCategory::Documentation,
tags: vec!["docs".to_string(), "technical".to_string()],
is_system: true,
organization_id: None,
workspace_id: None,
created_by: system_user,
created_at: now,
updated_at: now,
use_count: 0,
},
]
}

781
src/workspaces/ui.rs Normal file
View file

@ -0,0 +1,781 @@
use axum::{
extract::{Path, Query, State},
response::Html,
routing::get,
Router,
};
use diesel::prelude::*;
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::bot::get_default_bot;
use crate::core::shared::schema::{workspace_members, workspace_pages, workspaces};
use crate::shared::state::AppState;
use super::{DbWorkspace, DbWorkspaceMember, DbWorkspacePage};
#[derive(Debug, Deserialize)]
pub struct ListQuery {
pub search: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct PageListQuery {
pub parent_id: Option<Uuid>,
}
fn get_bot_context(state: &AppState) -> (Uuid, Uuid) {
let Ok(mut conn) = state.conn.get() else {
return (Uuid::nil(), Uuid::nil());
};
let (bot_id, _bot_name) = get_default_bot(&mut conn);
let org_id = Uuid::nil();
(org_id, bot_id)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
fn render_empty_state(icon: &str, title: &str, description: &str) -> String {
format!(
r##"<div class="empty-state">
<div class="empty-icon">{icon}</div>
<h3>{title}</h3>
<p>{description}</p>
</div>"##
)
}
fn render_workspace_card(workspace: &DbWorkspace, member_count: i64, page_count: i64) -> String {
let name = html_escape(&workspace.name);
let description = workspace
.description
.as_deref()
.map(html_escape)
.unwrap_or_else(|| "No description".to_string());
let updated = workspace.updated_at.format("%Y-%m-%d %H:%M").to_string();
let id = workspace.id;
let icon = workspace
.icon_value
.as_deref()
.unwrap_or("📁");
format!(
r##"<div class="workspace-card" data-id="{id}">
<div class="workspace-icon">{icon}</div>
<div class="workspace-info">
<h4 class="workspace-name">{name}</h4>
<p class="workspace-description">{description}</p>
<div class="workspace-meta">
<span class="workspace-members">{member_count} members</span>
<span class="workspace-pages">{page_count} pages</span>
<span class="workspace-updated">{updated}</span>
</div>
</div>
<div class="workspace-actions">
<button class="btn btn-sm btn-primary" hx-get="/api/ui/workspaces/{id}/pages" hx-target="#workspace-content" hx-swap="innerHTML">
Open
</button>
<button class="btn btn-sm btn-secondary" hx-get="/api/ui/workspaces/{id}/settings" hx-target="#modal-content" hx-swap="innerHTML">
Settings
</button>
<button class="btn btn-sm btn-danger" hx-delete="/api/workspaces/{id}" hx-confirm="Delete this workspace?" hx-swap="none">
Delete
</button>
</div>
</div>"##
)
}
fn render_workspace_row(workspace: &DbWorkspace, member_count: i64, page_count: i64) -> String {
let name = html_escape(&workspace.name);
let description = workspace
.description
.as_deref()
.map(html_escape)
.unwrap_or_else(|| "-".to_string());
let updated = workspace.updated_at.format("%Y-%m-%d %H:%M").to_string();
let id = workspace.id;
let icon = workspace.icon_value.as_deref().unwrap_or("📁");
format!(
r##"<tr class="workspace-row" data-id="{id}">
<td class="workspace-icon">{icon}</td>
<td class="workspace-name">
<a href="#" hx-get="/api/ui/workspaces/{id}/pages" hx-target="#workspace-content" hx-swap="innerHTML">{name}</a>
</td>
<td class="workspace-description">{description}</td>
<td class="workspace-members">{member_count}</td>
<td class="workspace-pages">{page_count}</td>
<td class="workspace-updated">{updated}</td>
<td class="workspace-actions">
<button class="btn btn-xs btn-primary" hx-get="/api/ui/workspaces/{id}/pages" hx-target="#workspace-content">Open</button>
<button class="btn btn-xs btn-danger" hx-delete="/api/workspaces/{id}" hx-confirm="Delete?" hx-swap="none">Delete</button>
</td>
</tr>"##
)
}
fn render_page_item(page: &DbWorkspacePage, child_count: i64) -> String {
let title = html_escape(&page.title);
let id = page.id;
let workspace_id = page.workspace_id;
let icon = page.icon_value.as_deref().unwrap_or("📄");
let updated = page.updated_at.format("%Y-%m-%d %H:%M").to_string();
let has_children = if child_count > 0 {
format!(
r##"<button class="btn-expand" hx-get="/api/ui/workspaces/{workspace_id}/pages?parent_id={id}" hx-target="#children-{id}" hx-swap="innerHTML">
<span class="expand-icon"></span>
</button>"##
)
} else {
r##"<span class="no-expand"></span>"##.to_string()
};
format!(
r##"<div class="page-item" data-id="{id}">
<div class="page-row">
{has_children}
<span class="page-icon">{icon}</span>
<a class="page-title" href="#" hx-get="/api/ui/pages/{id}" hx-target="#page-content" hx-swap="innerHTML">{title}</a>
<span class="page-updated">{updated}</span>
<div class="page-actions">
<button class="btn btn-xs" hx-get="/api/ui/pages/{id}/edit" hx-target="#modal-content">Edit</button>
<button class="btn btn-xs btn-danger" hx-delete="/api/pages/{id}" hx-confirm="Delete?" hx-swap="none">Delete</button>
</div>
</div>
<div class="page-children" id="children-{id}"></div>
</div>"##
)
}
pub async fn workspace_list(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let (org_id, bot_id) = get_bot_context(&state);
let mut q = workspaces::table
.filter(workspaces::org_id.eq(org_id))
.filter(workspaces::bot_id.eq(bot_id))
.into_boxed();
if let Some(search) = &query.search {
let pattern = format!("%{search}%");
q = q.filter(
workspaces::name
.ilike(pattern.clone())
.or(workspaces::description.ilike(pattern)),
);
}
let db_workspaces: Vec<DbWorkspace> = match q
.order(workspaces::updated_at.desc())
.limit(50)
.load(&mut conn)
{
Ok(w) => w,
Err(_) => {
return Html(render_empty_state("⚠️", "Error", "Failed to load workspaces"));
}
};
if db_workspaces.is_empty() {
return Html(render_empty_state(
"📁",
"No Workspaces",
"Create your first workspace to get started",
));
}
let mut rows = String::new();
for workspace in &db_workspaces {
let member_count: i64 = workspace_members::table
.filter(workspace_members::workspace_id.eq(workspace.id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let page_count: i64 = workspace_pages::table
.filter(workspace_pages::workspace_id.eq(workspace.id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
rows.push_str(&render_workspace_row(workspace, member_count, page_count));
}
Html(format!(
r##"<table class="table workspace-table">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Description</th>
<th>Members</th>
<th>Pages</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>"##
))
}
pub async fn workspace_cards(
State(state): State<Arc<AppState>>,
Query(query): Query<ListQuery>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let (org_id, bot_id) = get_bot_context(&state);
let mut q = workspaces::table
.filter(workspaces::org_id.eq(org_id))
.filter(workspaces::bot_id.eq(bot_id))
.into_boxed();
if let Some(search) = &query.search {
let pattern = format!("%{search}%");
q = q.filter(
workspaces::name
.ilike(pattern.clone())
.or(workspaces::description.ilike(pattern)),
);
}
let db_workspaces: Vec<DbWorkspace> = match q
.order(workspaces::updated_at.desc())
.limit(50)
.load(&mut conn)
{
Ok(w) => w,
Err(_) => {
return Html(render_empty_state("⚠️", "Error", "Failed to load workspaces"));
}
};
if db_workspaces.is_empty() {
return Html(render_empty_state(
"📁",
"No Workspaces",
"Create your first workspace to get started",
));
}
let mut cards = String::new();
for workspace in &db_workspaces {
let member_count: i64 = workspace_members::table
.filter(workspace_members::workspace_id.eq(workspace.id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let page_count: i64 = workspace_pages::table
.filter(workspace_pages::workspace_id.eq(workspace.id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
cards.push_str(&render_workspace_card(workspace, member_count, page_count));
}
Html(format!(r##"<div class="workspace-grid">{cards}</div>"##))
}
pub async fn workspace_count(State(state): State<Arc<AppState>>) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html("0".to_string());
};
let (org_id, bot_id) = get_bot_context(&state);
let count: i64 = workspaces::table
.filter(workspaces::org_id.eq(org_id))
.filter(workspaces::bot_id.eq(bot_id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
Html(count.to_string())
}
pub async fn workspace_detail(
State(state): State<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let workspace: DbWorkspace = match workspaces::table
.filter(workspaces::id.eq(workspace_id))
.first(&mut conn)
{
Ok(w) => w,
Err(_) => {
return Html(render_empty_state("", "Not Found", "Workspace not found"));
}
};
let member_count: i64 = workspace_members::table
.filter(workspace_members::workspace_id.eq(workspace_id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let page_count: i64 = workspace_pages::table
.filter(workspace_pages::workspace_id.eq(workspace_id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
let name = html_escape(&workspace.name);
let description = workspace
.description
.as_deref()
.map(html_escape)
.unwrap_or_else(|| "No description".to_string());
let icon = workspace.icon_value.as_deref().unwrap_or("📁");
let created = workspace.created_at.format("%Y-%m-%d %H:%M").to_string();
let updated = workspace.updated_at.format("%Y-%m-%d %H:%M").to_string();
Html(format!(
r##"<div class="workspace-detail">
<div class="workspace-header">
<span class="workspace-icon-large">{icon}</span>
<div class="workspace-title">
<h2>{name}</h2>
<p class="workspace-description">{description}</p>
</div>
</div>
<div class="workspace-stats">
<div class="stat">
<span class="stat-label">Members</span>
<span class="stat-value">{member_count}</span>
</div>
<div class="stat">
<span class="stat-label">Pages</span>
<span class="stat-value">{page_count}</span>
</div>
</div>
<div class="workspace-dates">
<span>Created: {created}</span>
<span>Updated: {updated}</span>
</div>
<div class="workspace-actions">
<button class="btn btn-primary" hx-get="/api/ui/workspaces/{workspace_id}/pages" hx-target="#workspace-content" hx-swap="innerHTML">
View Pages
</button>
<button class="btn btn-secondary" hx-get="/api/ui/workspaces/{workspace_id}/members" hx-target="#workspace-content" hx-swap="innerHTML">
Manage Members
</button>
<button class="btn btn-secondary" hx-get="/api/ui/workspaces/{workspace_id}/settings" hx-target="#modal-content" hx-swap="innerHTML">
Settings
</button>
</div>
</div>"##
))
}
pub async fn workspace_pages(
State(state): State<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
Query(query): Query<PageListQuery>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let pages: Vec<DbWorkspacePage> = match query.parent_id {
Some(parent_id) => workspace_pages::table
.filter(workspace_pages::workspace_id.eq(workspace_id))
.filter(workspace_pages::parent_id.eq(parent_id))
.order(workspace_pages::position.asc())
.load(&mut conn)
.unwrap_or_default(),
None => workspace_pages::table
.filter(workspace_pages::workspace_id.eq(workspace_id))
.filter(workspace_pages::parent_id.is_null())
.order(workspace_pages::position.asc())
.load(&mut conn)
.unwrap_or_default(),
};
if pages.is_empty() && query.parent_id.is_none() {
return Html(render_empty_state(
"📄",
"No Pages",
"Create your first page to get started",
));
}
let mut items = String::new();
for page in &pages {
let child_count: i64 = workspace_pages::table
.filter(workspace_pages::parent_id.eq(page.id))
.count()
.get_result(&mut conn)
.unwrap_or(0);
items.push_str(&render_page_item(page, child_count));
}
if query.parent_id.is_some() {
Html(items)
} else {
Html(format!(
r##"<div class="workspace-pages-header">
<h3>Pages</h3>
<button class="btn btn-primary" hx-get="/api/ui/workspaces/{workspace_id}/pages/new" hx-target="#modal-content" hx-swap="innerHTML">
New Page
</button>
</div>
<div class="page-tree">{items}</div>"##
))
}
}
pub async fn workspace_members(
State(state): State<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let members: Vec<DbWorkspaceMember> = workspace_members::table
.filter(workspace_members::workspace_id.eq(workspace_id))
.order(workspace_members::joined_at.asc())
.load(&mut conn)
.unwrap_or_default();
if members.is_empty() {
return Html(render_empty_state(
"👥",
"No Members",
"This workspace has no members",
));
}
let mut rows = String::new();
for member in &members {
let user_id = member.user_id;
let role = html_escape(&member.role);
let joined = member.joined_at.format("%Y-%m-%d").to_string();
let role_class = match role.as_str() {
"owner" => "badge-primary",
"admin" => "badge-warning",
"editor" => "badge-info",
_ => "badge-secondary",
};
rows.push_str(&format!(
r##"<tr class="member-row" data-user-id="{user_id}">
<td class="member-user">{user_id}</td>
<td class="member-role"><span class="badge {role_class}">{role}</span></td>
<td class="member-joined">{joined}</td>
<td class="member-actions">
<button class="btn btn-xs btn-danger" hx-delete="/api/workspaces/{workspace_id}/members/{user_id}" hx-confirm="Remove member?" hx-swap="none">
Remove
</button>
</td>
</tr>"##
));
}
Html(format!(
r##"<div class="workspace-members-header">
<h3>Members</h3>
<button class="btn btn-primary" hx-get="/api/ui/workspaces/{workspace_id}/members/add" hx-target="#modal-content" hx-swap="innerHTML">
Add Member
</button>
</div>
<table class="table members-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>"##
))
}
pub async fn page_detail(
State(state): State<Arc<AppState>>,
Path(page_id): Path<Uuid>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let page: DbWorkspacePage = match workspace_pages::table
.filter(workspace_pages::id.eq(page_id))
.first(&mut conn)
{
Ok(p) => p,
Err(_) => {
return Html(render_empty_state("", "Not Found", "Page not found"));
}
};
let title = html_escape(&page.title);
let icon = page.icon_value.as_deref().unwrap_or("📄");
let created = page.created_at.format("%Y-%m-%d %H:%M").to_string();
let updated = page.updated_at.format("%Y-%m-%d %H:%M").to_string();
let workspace_id = page.workspace_id;
let content_preview = if page.content.is_null() || page.content == serde_json::json!([]) {
r##"<p class="text-muted">This page is empty. Click Edit to add content.</p>"##.to_string()
} else {
r##"<div class="page-blocks" id="page-blocks" hx-get="/api/ui/pages/{page_id}/blocks" hx-trigger="load" hx-swap="innerHTML"></div>"##.to_string().replace("{page_id}", &page_id.to_string())
};
Html(format!(
r##"<div class="page-detail">
<div class="page-header">
<div class="page-breadcrumb" hx-get="/api/ui/pages/{page_id}/breadcrumb" hx-trigger="load" hx-swap="innerHTML"></div>
<div class="page-title-row">
<span class="page-icon-large">{icon}</span>
<h2 class="page-title">{title}</h2>
</div>
</div>
<div class="page-meta">
<span>Created: {created}</span>
<span>Updated: {updated}</span>
</div>
<div class="page-actions">
<button class="btn btn-primary" hx-get="/api/ui/pages/{page_id}/edit" hx-target="#modal-content" hx-swap="innerHTML">
Edit
</button>
<button class="btn btn-secondary" hx-get="/api/ui/workspaces/{workspace_id}/pages/new?parent_id={page_id}" hx-target="#modal-content" hx-swap="innerHTML">
Add Subpage
</button>
<button class="btn btn-danger" hx-delete="/api/pages/{page_id}" hx-confirm="Delete this page?" hx-swap="none">
Delete
</button>
</div>
<div class="page-content">
{content_preview}
</div>
</div>"##
))
}
pub async fn new_workspace_form(State(_state): State<Arc<AppState>>) -> Html<String> {
Html(
r##"<div class="modal-header">
<h3>New Workspace</h3>
<button class="btn-close" onclick="closeModal()">&times;</button>
</div>
<form class="workspace-form" hx-post="/api/workspaces" hx-swap="none" hx-on::after-request="closeModal(); htmx.trigger('#workspace-list', 'refresh');">
<div class="form-group">
<label>Name</label>
<input type="text" name="name" placeholder="My Workspace" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" rows="3" placeholder="Describe your workspace..."></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Workspace</button>
</div>
</form>"##
.to_string(),
)
}
pub async fn new_page_form(
State(_state): State<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
Query(query): Query<PageListQuery>,
) -> Html<String> {
let parent_input = match query.parent_id {
Some(parent_id) => format!(r##"<input type="hidden" name="parent_id" value="{parent_id}" />"##),
None => String::new(),
};
Html(format!(
r##"<div class="modal-header">
<h3>New Page</h3>
<button class="btn-close" onclick="closeModal()">&times;</button>
</div>
<form class="page-form" hx-post="/api/workspaces/{workspace_id}/pages" hx-swap="none" hx-on::after-request="closeModal(); htmx.trigger('#page-tree', 'refresh');">
{parent_input}
<div class="form-group">
<label>Title</label>
<input type="text" name="title" placeholder="Page Title" required />
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Page</button>
</div>
</form>"##
))
}
pub async fn workspace_settings(
State(state): State<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let workspace: DbWorkspace = match workspaces::table
.filter(workspaces::id.eq(workspace_id))
.first(&mut conn)
{
Ok(w) => w,
Err(_) => {
return Html(render_empty_state("", "Not Found", "Workspace not found"));
}
};
let name = html_escape(&workspace.name);
let description = workspace.description.as_deref().map(html_escape).unwrap_or_default();
Html(format!(
r##"<div class="modal-header">
<h3>Workspace Settings</h3>
<button class="btn-close" onclick="closeModal()">&times;</button>
</div>
<form class="workspace-settings-form" hx-put="/api/workspaces/{workspace_id}" hx-swap="none" hx-on::after-request="closeModal()">
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="{name}" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea name="description" rows="3">{description}</textarea>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>"##
))
}
pub async fn add_member_form(
State(_state): State<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
) -> Html<String> {
Html(format!(
r##"<div class="modal-header">
<h3>Add Member</h3>
<button class="btn-close" onclick="closeModal()">&times;</button>
</div>
<form class="add-member-form" hx-post="/api/workspaces/{workspace_id}/members" hx-swap="none" hx-on::after-request="closeModal(); htmx.trigger('#members-table', 'refresh');">
<div class="form-group">
<label>User ID</label>
<input type="text" name="user_id" placeholder="User UUID" required />
</div>
<div class="form-group">
<label>Role</label>
<select name="role" required>
<option value="viewer">Viewer</option>
<option value="commenter">Commenter</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Add Member</button>
</div>
</form>"##
))
}
pub async fn search_results(
State(state): State<Arc<AppState>>,
Path(workspace_id): Path<Uuid>,
Query(query): Query<ListQuery>,
) -> Html<String> {
let Ok(mut conn) = state.conn.get() else {
return Html(render_empty_state("⚠️", "Database Error", "Could not connect to database"));
};
let search_term = match &query.search {
Some(s) if !s.is_empty() => s,
_ => {
return Html(render_empty_state("🔍", "Search", "Enter a search term"));
}
};
let pattern = format!("%{search_term}%");
let pages: Vec<DbWorkspacePage> = workspace_pages::table
.filter(workspace_pages::workspace_id.eq(workspace_id))
.filter(workspace_pages::title.ilike(&pattern))
.order(workspace_pages::updated_at.desc())
.limit(20)
.load(&mut conn)
.unwrap_or_default();
if pages.is_empty() {
return Html(render_empty_state(
"🔍",
"No Results",
"No pages match your search",
));
}
let mut items = String::new();
for page in &pages {
let title = html_escape(&page.title);
let id = page.id;
let icon = page.icon_value.as_deref().unwrap_or("📄");
let updated = page.updated_at.format("%Y-%m-%d %H:%M").to_string();
items.push_str(&format!(
r##"<div class="search-result" data-id="{id}">
<span class="result-icon">{icon}</span>
<a class="result-title" href="#" hx-get="/api/ui/pages/{id}" hx-target="#page-content" hx-swap="innerHTML">{title}</a>
<span class="result-updated">{updated}</span>
</div>"##
));
}
Html(format!(
r##"<div class="search-results">
<h4>Search Results ({count})</h4>
{items}
</div>"##,
count = pages.len()
))
}
pub fn configure_workspaces_ui_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/ui/workspaces", get(workspace_list))
.route("/api/ui/workspaces/cards", get(workspace_cards))
.route("/api/ui/workspaces/count", get(workspace_count))
.route("/api/ui/workspaces/new", get(new_workspace_form))
.route("/api/ui/workspaces/{workspace_id}", get(workspace_detail))
.route("/api/ui/workspaces/{workspace_id}/pages", get(workspace_pages))
.route("/api/ui/workspaces/{workspace_id}/pages/new", get(new_page_form))
.route("/api/ui/workspaces/{workspace_id}/members", get(workspace_members))
.route("/api/ui/workspaces/{workspace_id}/members/add", get(add_member_form))
.route("/api/ui/workspaces/{workspace_id}/settings", get(workspace_settings))
.route("/api/ui/workspaces/{workspace_id}/search", get(search_results))
.route("/api/ui/pages/{page_id}", get(page_detail))
}