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:
parent
67c9b0e0cc
commit
a886478548
65 changed files with 25109 additions and 5165 deletions
|
|
@ -115,7 +115,8 @@ base64 = "0.22"
|
||||||
bytes = "1.8"
|
bytes = "1.8"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
color-eyre = "0.6.5"
|
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"
|
diesel_migrations = "2.1.0"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
|
|
||||||
7
migrations/20250717000001_add_crm_tables/down.sql
Normal file
7
migrations/20250717000001_add_crm_tables/down.sql
Normal 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;
|
||||||
231
migrations/20250717000001_add_crm_tables/up.sql
Normal file
231
migrations/20250717000001_add_crm_tables/up.sql
Normal 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;
|
||||||
30
migrations/20250718000001_add_tickets_tables/down.sql
Normal file
30
migrations/20250718000001_add_tickets_tables/down.sql
Normal 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;
|
||||||
113
migrations/20250718000001_add_tickets_tables/up.sql
Normal file
113
migrations/20250718000001_add_tickets_tables/up.sql
Normal 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);
|
||||||
36
migrations/20250719000001_add_billing_tables/down.sql
Normal file
36
migrations/20250719000001_add_billing_tables/down.sql
Normal 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;
|
||||||
172
migrations/20250719000001_add_billing_tables/up.sql
Normal file
172
migrations/20250719000001_add_billing_tables/up.sql
Normal 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);
|
||||||
36
migrations/20250720000001_add_products_tables/down.sql
Normal file
36
migrations/20250720000001_add_products_tables/down.sql
Normal 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;
|
||||||
139
migrations/20250720000001_add_products_tables/up.sql
Normal file
139
migrations/20250720000001_add_products_tables/up.sql
Normal 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);
|
||||||
43
migrations/20250721000001_add_people_tables/down.sql
Normal file
43
migrations/20250721000001_add_people_tables/down.sql
Normal 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;
|
||||||
160
migrations/20250721000001_add_people_tables/up.sql
Normal file
160
migrations/20250721000001_add_people_tables/up.sql
Normal 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);
|
||||||
43
migrations/20250722000001_add_attendant_tables/down.sql
Normal file
43
migrations/20250722000001_add_attendant_tables/down.sql
Normal 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;
|
||||||
183
migrations/20250722000001_add_attendant_tables/up.sql
Normal file
183
migrations/20250722000001_add_attendant_tables/up.sql
Normal 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);
|
||||||
26
migrations/20250723000001_add_calendar_tables/down.sql
Normal file
26
migrations/20250723000001_add_calendar_tables/down.sql
Normal 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;
|
||||||
95
migrations/20250723000001_add_calendar_tables/up.sql
Normal file
95
migrations/20250723000001_add_calendar_tables/up.sql
Normal 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;
|
||||||
43
migrations/20250724000001_add_goals_tables/down.sql
Normal file
43
migrations/20250724000001_add_goals_tables/down.sql
Normal 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;
|
||||||
150
migrations/20250724000001_add_goals_tables/up.sql
Normal file
150
migrations/20250724000001_add_goals_tables/up.sql
Normal 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);
|
||||||
25
migrations/20250725000001_add_canvas_tables/down.sql
Normal file
25
migrations/20250725000001_add_canvas_tables/down.sql
Normal 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;
|
||||||
90
migrations/20250725000001_add_canvas_tables/up.sql
Normal file
90
migrations/20250725000001_add_canvas_tables/up.sql
Normal 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;
|
||||||
39
migrations/20250726000001_add_workspaces_tables/down.sql
Normal file
39
migrations/20250726000001_add_workspaces_tables/down.sql
Normal 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;
|
||||||
141
migrations/20250726000001_add_workspaces_tables/up.sql
Normal file
141
migrations/20250726000001_add_workspaces_tables/up.sql
Normal 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;
|
||||||
63
migrations/20250727000001_add_social_tables/down.sql
Normal file
63
migrations/20250727000001_add_social_tables/down.sql
Normal 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;
|
||||||
219
migrations/20250727000001_add_social_tables/up.sql
Normal file
219
migrations/20250727000001_add_social_tables/up.sql
Normal 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);
|
||||||
34
migrations/20250728000001_add_research_tables/down.sql
Normal file
34
migrations/20250728000001_add_research_tables/down.sql
Normal 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;
|
||||||
118
migrations/20250728000001_add_research_tables/up.sql
Normal file
118
migrations/20250728000001_add_research_tables/up.sql
Normal 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);
|
||||||
13
migrations/20250729000001_add_dashboards_tables/down.sql
Normal file
13
migrations/20250729000001_add_dashboards_tables/down.sql
Normal 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;
|
||||||
100
migrations/20250729000001_add_dashboards_tables/up.sql
Normal file
100
migrations/20250729000001_add_dashboards_tables/up.sql
Normal 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);
|
||||||
12
migrations/20250730000001_add_legal_tables/down.sql
Normal file
12
migrations/20250730000001_add_legal_tables/down.sql
Normal 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;
|
||||||
140
migrations/20250730000001_add_legal_tables/up.sql
Normal file
140
migrations/20250730000001_add_legal_tables/up.sql
Normal 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);
|
||||||
18
migrations/20250731000001_add_compliance_tables/down.sql
Normal file
18
migrations/20250731000001_add_compliance_tables/down.sql
Normal 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;
|
||||||
182
migrations/20250731000001_add_compliance_tables/up.sql
Normal file
182
migrations/20250731000001_add_compliance_tables/up.sql
Normal 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
411
src/analytics/goals_ui.rs
Normal 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()">×</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))
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod goals;
|
pub mod goals;
|
||||||
|
pub mod goals_ui;
|
||||||
pub mod insights;
|
pub mod insights;
|
||||||
|
|
||||||
use crate::core::urls::ApiUrls;
|
use crate::core::urls::ApiUrls;
|
||||||
|
|
|
||||||
1077
src/attendant/mod.rs
Normal file
1077
src/attendant/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
623
src/attendant/ui.rs
Normal file
623
src/attendant/ui.rs
Normal 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
1110
src/billing/api.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,14 +4,25 @@ use axum::{
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use bigdecimal::{BigDecimal, ToPrimitive};
|
||||||
|
use chrono::{DateTime, Datelike, NaiveDate, Utc};
|
||||||
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
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;
|
use crate::shared::state::AppState;
|
||||||
|
|
||||||
|
fn bd_to_f64(bd: &BigDecimal) -> f64 {
|
||||||
|
bd.to_f64().unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct StatusQuery {
|
pub struct StatusQuery {
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -19,6 +30,23 @@ pub struct SearchQuery {
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn html_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>> {
|
pub fn configure_billing_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/billing/invoices", get(handle_invoices))
|
.route("/api/billing/invoices", get(handle_invoices))
|
||||||
|
|
@ -32,81 +60,460 @@ pub fn configure_billing_routes() -> Router<Arc<AppState>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_invoices(
|
async fn handle_invoices(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(_query): Query<StatusQuery>,
|
Query(query): Query<StatusQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
Html(
|
let pool = state.conn.clone();
|
||||||
r#"<tr class="empty-row">
|
|
||||||
<td colspan="7" class="empty-state">
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
<div class="empty-icon">📄</div>
|
let mut conn = pool.get().ok()?;
|
||||||
<p>No invoices yet</p>
|
let (bot_id, _) = get_default_bot(&mut conn);
|
||||||
<p class="empty-hint">Create your first invoice to get started</p>
|
|
||||||
</td>
|
let mut db_query = billing_invoices::table
|
||||||
</tr>"#
|
.filter(billing_invoices::bot_id.eq(bot_id))
|
||||||
.to_string(),
|
.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), ¤cy);
|
||||||
|
let due_str = format_currency(bd_to_f64(&amount_due), ¤cy);
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_payments(
|
async fn handle_payments(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(_query): Query<StatusQuery>,
|
Query(query): Query<StatusQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
Html(
|
let pool = state.conn.clone();
|
||||||
r#"<tr class="empty-row">
|
|
||||||
<td colspan="6" class="empty-state">
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
<div class="empty-icon">💳</div>
|
let mut conn = pool.get().ok()?;
|
||||||
<p>No payments recorded</p>
|
let (bot_id, _) = get_default_bot(&mut conn);
|
||||||
<p class="empty-hint">Payments will appear here when invoices are paid</p>
|
|
||||||
</td>
|
let mut db_query = billing_payments::table
|
||||||
</tr>"#
|
.filter(billing_payments::bot_id.eq(bot_id))
|
||||||
.to_string(),
|
.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), ¤cy);
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_quotes(
|
async fn handle_quotes(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(_query): Query<StatusQuery>,
|
Query(query): Query<StatusQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
Html(
|
let pool = state.conn.clone();
|
||||||
r#"<tr class="empty-row">
|
|
||||||
<td colspan="6" class="empty-state">
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
<div class="empty-icon">📝</div>
|
let mut conn = pool.get().ok()?;
|
||||||
<p>No quotes yet</p>
|
let (bot_id, _) = get_default_bot(&mut conn);
|
||||||
<p class="empty-hint">Create quotes for your prospects</p>
|
|
||||||
</td>
|
let mut db_query = billing_quotes::table
|
||||||
</tr>"#
|
.filter(billing_quotes::bot_id.eq(bot_id))
|
||||||
.to_string(),
|
.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), ¤cy);
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_stats_pending(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn handle_stats_pending(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
Html("$0".to_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 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 {
|
async fn handle_revenue_month(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
Html("$0".to_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 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 {
|
async fn handle_paid_month(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
Html("$0".to_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 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 {
|
async fn handle_overdue(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
Html("$0".to_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 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(
|
async fn handle_billing_search(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<SearchQuery>,
|
Query(query): Query<SearchQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let q = query.q.unwrap_or_default();
|
let q = query.q.clone().unwrap_or_default();
|
||||||
if q.is_empty() {
|
if q.is_empty() {
|
||||||
return Html(String::new());
|
return Html(String::new());
|
||||||
}
|
}
|
||||||
Html(format!(
|
|
||||||
r#"<div class="search-results-empty">
|
let pool = state.conn.clone();
|
||||||
<p>No results for "{}"</p>
|
let search_term = format!("%{}%", q);
|
||||||
</div>"#,
|
|
||||||
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), ¤cy);
|
||||||
|
|
||||||
|
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)
|
||||||
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use tokio::sync::RwLock;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub mod alerts;
|
pub mod alerts;
|
||||||
|
pub mod api;
|
||||||
pub mod billing_ui;
|
pub mod billing_ui;
|
||||||
pub mod invoice;
|
pub mod invoice;
|
||||||
pub mod lifecycle;
|
pub mod lifecycle;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use super::CalendarEngine;
|
use crate::basic::keywords::book::CalendarEngine;
|
||||||
use crate::shared::state::AppState;
|
use crate::shared::state::AppState;
|
||||||
|
|
||||||
pub fn create_caldav_router(_engine: Arc<CalendarEngine>) -> Router<Arc<AppState>> {
|
pub fn create_caldav_router(_engine: Arc<CalendarEngine>) -> Router<Arc<AppState>> {
|
||||||
|
|
|
||||||
1145
src/calendar/mod.rs
1145
src/calendar/mod.rs
File diff suppressed because it is too large
Load diff
780
src/calendar/ui.rs
Normal file
780
src/calendar/ui.rs
Normal 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"><</button>
|
||||||
|
<h3>{}</h3>
|
||||||
|
<button class="btn-icon" hx-get="/api/ui/calendar/month?year={}&month={}" hx-target="#calendar-view">></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()">×</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()">×</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))
|
||||||
|
}
|
||||||
1339
src/canvas/mod.rs
1339
src/canvas/mod.rs
File diff suppressed because it is too large
Load diff
753
src/canvas/ui.rs
Normal file
753
src/canvas/ui.rs
Normal 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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
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()">×</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()">×</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
1220
src/contacts/crm.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,11 +2,16 @@ use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use diesel::prelude::*;
|
||||||
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
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;
|
use crate::shared::state::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -19,14 +24,13 @@ pub struct SearchQuery {
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
fn get_bot_context(state: &AppState) -> (Uuid, Uuid) {
|
||||||
pub struct CountResponse {
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
pub count: i64,
|
return (Uuid::nil(), Uuid::nil());
|
||||||
}
|
};
|
||||||
|
let (bot_id, _bot_name) = get_default_bot(&mut conn);
|
||||||
#[derive(Debug, Serialize)]
|
let org_id = Uuid::nil();
|
||||||
pub struct StatsResponse {
|
(org_id, bot_id)
|
||||||
pub value: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn configure_crm_routes() -> Router<Arc<AppState>> {
|
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(
|
async fn handle_crm_count(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<StageQuery>,
|
Query(query): Query<StageQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let _stage = query.stage.unwrap_or_else(|| "all".to_string());
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
Html("0".to_string())
|
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(
|
async fn handle_crm_pipeline(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<StageQuery>,
|
Query(query): Query<StageQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let stage = query.stage.unwrap_or_else(|| "lead".to_string());
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
Html(format!(
|
return Html(render_empty_pipeline("lead"));
|
||||||
r#"<div class="pipeline-empty">
|
};
|
||||||
<p>No {} items yet</p>
|
|
||||||
</div>"#,
|
let (org_id, bot_id) = get_bot_context(&state);
|
||||||
stage
|
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(
|
async fn handle_crm_leads(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
State(_state): State<Arc<AppState>>,
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
) -> impl IntoResponse {
|
return Html(render_empty_table("leads", "📋", "No leads yet", "Create your first lead to get started"));
|
||||||
Html(r#"<tr class="empty-row">
|
};
|
||||||
<td colspan="7" class="empty-state">
|
|
||||||
<div class="empty-icon">📋</div>
|
let (org_id, bot_id) = get_bot_context(&state);
|
||||||
<p>No leads yet</p>
|
|
||||||
<p class="empty-hint">Create your first lead to get started</p>
|
let leads: Vec<CrmLead> = crm_leads::table
|
||||||
</td>
|
.filter(crm_leads::org_id.eq(org_id))
|
||||||
</tr>"#.to_string())
|
.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>",
|
||||||
|
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(
|
async fn handle_crm_opportunities(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
State(_state): State<Arc<AppState>>,
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
) -> impl IntoResponse {
|
return Html(render_empty_table("opportunities", "💼", "No opportunities yet", "Qualify leads to create opportunities"));
|
||||||
Html(r#"<tr class="empty-row">
|
};
|
||||||
<td colspan="7" class="empty-state">
|
|
||||||
<div class="empty-icon">💼</div>
|
let (org_id, bot_id) = get_bot_context(&state);
|
||||||
<p>No opportunities yet</p>
|
|
||||||
<p class="empty-hint">Qualify leads to create opportunities</p>
|
let opportunities: Vec<CrmOpportunity> = crm_opportunities::table
|
||||||
</td>
|
.filter(crm_opportunities::org_id.eq(org_id))
|
||||||
</tr>"#.to_string())
|
.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>",
|
||||||
|
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(
|
async fn handle_crm_contacts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
State(_state): State<Arc<AppState>>,
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
) -> impl IntoResponse {
|
return Html(render_empty_table("contacts", "👥", "No contacts yet", "Add contacts to your CRM"));
|
||||||
Html(r#"<tr class="empty-row">
|
};
|
||||||
<td colspan="6" class="empty-state">
|
|
||||||
<div class="empty-icon">👥</div>
|
let (org_id, bot_id) = get_bot_context(&state);
|
||||||
<p>No contacts yet</p>
|
|
||||||
<p class="empty-hint">Add contacts to your CRM</p>
|
let contacts: Vec<CrmContact> = crm_contacts::table
|
||||||
</td>
|
.filter(crm_contacts::org_id.eq(org_id))
|
||||||
</tr>"#.to_string())
|
.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>",
|
||||||
|
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(
|
async fn handle_crm_accounts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
State(_state): State<Arc<AppState>>,
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
) -> impl IntoResponse {
|
return Html(render_empty_table("accounts", "🏢", "No accounts yet", "Add company accounts to your CRM"));
|
||||||
Html(r#"<tr class="empty-row">
|
};
|
||||||
<td colspan="6" class="empty-state">
|
|
||||||
<div class="empty-icon">🏢</div>
|
let (org_id, bot_id) = get_bot_context(&state);
|
||||||
<p>No accounts yet</p>
|
|
||||||
<p class="empty-hint">Add company accounts to your CRM</p>
|
let accounts: Vec<CrmAccount> = crm_accounts::table
|
||||||
</td>
|
.filter(crm_accounts::org_id.eq(org_id))
|
||||||
</tr>"#.to_string())
|
.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>",
|
||||||
|
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(
|
async fn handle_crm_search(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<SearchQuery>,
|
Query(query): Query<SearchQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let q = query.q.unwrap_or_default();
|
let q = query.q.unwrap_or_default();
|
||||||
if q.is_empty() {
|
if q.is_empty() {
|
||||||
return Html(String::new());
|
return Html(String::new());
|
||||||
}
|
}
|
||||||
Html(format!(
|
|
||||||
r#"<div class="search-results-empty">
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
<p>No results for "{}"</p>
|
return Html("<div class=\"search-results-empty\"><p>Search unavailable</p></div>".to_string());
|
||||||
</div>"#,
|
};
|
||||||
q
|
|
||||||
))
|
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(
|
async fn handle_conversion_rate(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
State(_state): State<Arc<AppState>>,
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
) -> impl IntoResponse {
|
return Html("0%".to_string());
|
||||||
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(
|
async fn handle_pipeline_value(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
State(_state): State<Arc<AppState>>,
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
) -> impl IntoResponse {
|
return Html("$0".to_string());
|
||||||
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(
|
async fn handle_avg_deal(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
State(_state): State<Arc<AppState>>,
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
) -> impl IntoResponse {
|
return Html("$0".to_string());
|
||||||
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(
|
async fn handle_won_month(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
State(_state): State<Arc<AppState>>,
|
let Ok(mut conn) = state.conn.get() else {
|
||||||
) -> impl IntoResponse {
|
return Html("0".to_string());
|
||||||
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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod calendar_integration;
|
pub mod calendar_integration;
|
||||||
|
pub mod crm;
|
||||||
pub mod crm_ui;
|
pub mod crm_ui;
|
||||||
pub mod external_sync;
|
pub mod external_sync;
|
||||||
pub mod tasks_integration;
|
pub mod tasks_integration;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1230
src/legal/mod.rs
1230
src/legal/mod.rs
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,5 @@
|
||||||
|
#![recursion_limit = "512"]
|
||||||
|
|
||||||
pub mod auto_task;
|
pub mod auto_task;
|
||||||
pub mod basic;
|
pub mod basic;
|
||||||
pub mod billing;
|
pub mod billing;
|
||||||
|
|
@ -10,9 +12,12 @@ pub mod embedded_ui;
|
||||||
pub mod maintenance;
|
pub mod maintenance;
|
||||||
pub mod multimodal;
|
pub mod multimodal;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
pub mod people;
|
||||||
pub mod products;
|
pub mod products;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
|
pub mod tickets;
|
||||||
|
pub mod attendant;
|
||||||
|
|
||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod designer;
|
pub mod designer;
|
||||||
|
|
|
||||||
15
src/main.rs
15
src/main.rs
|
|
@ -358,6 +358,7 @@ async fn run_axum_server(
|
||||||
#[cfg(feature = "calendar")]
|
#[cfg(feature = "calendar")]
|
||||||
{
|
{
|
||||||
api_router = api_router.merge(crate::calendar::configure_calendar_routes());
|
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());
|
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::sources::configure_sources_routes());
|
||||||
api_router = api_router.merge(botserver::designer::configure_designer_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::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::monitoring::configure());
|
||||||
api_router = api_router.merge(botserver::security::configure_protection_routes());
|
api_router = api_router.merge(botserver::security::configure_protection_routes());
|
||||||
api_router = api_router.merge(botserver::settings::configure_settings_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(botserver::auto_task::configure_autotask_routes());
|
||||||
api_router = api_router.merge(crate::core::shared::admin::configure());
|
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::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::project::configure());
|
||||||
api_router = api_router.merge(botserver::analytics::goals::configure_goals_routes());
|
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::player::configure_player_routes());
|
||||||
api_router = api_router.merge(botserver::canvas::configure_canvas_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::social::configure_social_routes());
|
||||||
api_router = api_router.merge(botserver::contacts::crm_ui::configure_crm_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::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::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")]
|
#[cfg(feature = "whatsapp")]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
1087
src/people/mod.rs
Normal file
1087
src/people/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
836
src/people/ui.rs
Normal file
836
src/people/ui.rs
Normal 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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()">×</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
969
src/products/api.rs
Normal 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))
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,26 @@
|
||||||
|
pub mod api;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
response::{Html, IntoResponse},
|
response::{Html, IntoResponse},
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use bigdecimal::{BigDecimal, ToPrimitive};
|
||||||
|
use diesel::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
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;
|
use crate::shared::state::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ProductQuery {
|
pub struct ProductQuery {
|
||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -20,98 +28,477 @@ pub struct SearchQuery {
|
||||||
pub q: Option<String>,
|
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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>> {
|
pub fn configure_products_routes() -> Router<Arc<AppState>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/products/items", get(handle_products_items))
|
.route("/api/products/items", get(handle_products_items))
|
||||||
.route("/api/products/services", get(handle_products_services))
|
.route("/api/products/services", get(handle_products_services))
|
||||||
.route("/api/products/pricelists", get(handle_products_pricelists))
|
.route("/api/products/pricelists", get(handle_products_pricelists))
|
||||||
.route(
|
.route("/api/products/stats/total-products", get(handle_total_products))
|
||||||
"/api/products/stats/total-products",
|
.route("/api/products/stats/total-services", get(handle_total_services))
|
||||||
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/pricelists", get(handle_total_pricelists))
|
||||||
.route("/api/products/stats/active", get(handle_active_products))
|
.route("/api/products/stats/active", get(handle_active_products))
|
||||||
.route("/api/products/search", get(handle_products_search))
|
.route("/api/products/search", get(handle_products_search))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_products_items(
|
async fn handle_products_items(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(_query): Query<ProductQuery>,
|
Query(query): Query<ProductQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
Html(
|
let pool = state.conn.clone();
|
||||||
r#"<div class="products-empty">
|
|
||||||
<div class="empty-icon">📦</div>
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
<p>No products yet</p>
|
let mut conn = pool.get().ok()?;
|
||||||
<p class="empty-hint">Add your first product to get started</p>
|
let (bot_id, _) = get_default_bot(&mut conn);
|
||||||
</div>"#
|
|
||||||
.to_string(),
|
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), ¤cy);
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_products_services(
|
async fn handle_products_services(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(_query): Query<ProductQuery>,
|
Query(query): Query<ProductQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
Html(
|
let pool = state.conn.clone();
|
||||||
r#"<tr class="empty-row">
|
|
||||||
<td colspan="6" class="empty-state">
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
<div class="empty-icon">🔧</div>
|
let mut conn = pool.get().ok()?;
|
||||||
<p>No services yet</p>
|
let (bot_id, _) = get_default_bot(&mut conn);
|
||||||
<p class="empty-hint">Add services to your catalog</p>
|
|
||||||
</td>
|
let mut db_query = services::table
|
||||||
</tr>"#
|
.filter(services::bot_id.eq(bot_id))
|
||||||
.to_string(),
|
.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), ¤cy))
|
||||||
|
} else if let Some(ref f) = fixed {
|
||||||
|
format_currency(bd_to_f64(f), ¤cy)
|
||||||
|
} 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(),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_products_pricelists(
|
async fn handle_products_pricelists(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(_query): Query<ProductQuery>,
|
Query(query): Query<ProductQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
Html(
|
let pool = state.conn.clone();
|
||||||
r#"<tr class="empty-row">
|
|
||||||
<td colspan="5" class="empty-state">
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
<div class="empty-icon">💰</div>
|
let mut conn = pool.get().ok()?;
|
||||||
<p>No price lists yet</p>
|
let (bot_id, _) = get_default_bot(&mut conn);
|
||||||
<p class="empty-hint">Create price lists for different customer segments</p>
|
|
||||||
</td>
|
let mut db_query = price_lists::table
|
||||||
</tr>"#
|
.filter(price_lists::bot_id.eq(bot_id))
|
||||||
.to_string(),
|
.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(),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_total_products(State(_state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn handle_total_products(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
Html("0".to_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);
|
||||||
|
|
||||||
|
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 {
|
async fn handle_total_services(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
Html("0".to_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);
|
||||||
|
|
||||||
|
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 {
|
async fn handle_total_pricelists(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
Html("0".to_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);
|
||||||
|
|
||||||
|
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 {
|
async fn handle_active_products(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
Html("0".to_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);
|
||||||
|
|
||||||
|
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(
|
async fn handle_products_search(
|
||||||
State(_state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<SearchQuery>,
|
Query(query): Query<SearchQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let q = query.q.unwrap_or_default();
|
let q = query.q.clone().unwrap_or_default();
|
||||||
if q.is_empty() {
|
if q.is_empty() {
|
||||||
return Html(String::new());
|
return Html(String::new());
|
||||||
}
|
}
|
||||||
Html(format!(
|
|
||||||
r#"<div class="search-results-empty">
|
let pool = state.conn.clone();
|
||||||
<p>No results for "{}"</p>
|
let search_term = format!("%{}%", q);
|
||||||
</div>"#,
|
|
||||||
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), ¤cy);
|
||||||
|
|
||||||
|
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)
|
||||||
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1663
src/social/mod.rs
1663
src/social/mod.rs
File diff suppressed because it is too large
Load diff
897
src/tickets/mod.rs
Normal file
897
src/tickets/mod.rs
Normal 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
753
src/tickets/ui.rs
Normal 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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,10 +3,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Block, BlockContent, BlockProperties, BlockType, CalloutContent, ChecklistContent,
|
Block, BlockContent, BlockProperties, BlockType, ChecklistItem, RichText, TableCell, TableRow,
|
||||||
ChecklistItem, CodeContent, EmbedContent, EmbedType, GbComponentContent, GbComponentType,
|
TextAnnotations, TextSegment,
|
||||||
MediaContent, RichText, TableCell, TableContent, TableRow, TextAnnotations, TextSegment,
|
|
||||||
ToggleContent, WorkspaceIcon,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct BlockBuilder {
|
pub struct BlockBuilder {
|
||||||
|
|
@ -29,19 +27,21 @@ impl BlockBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_text(mut self, text: &str) -> Self {
|
pub fn with_text(mut self, text: &str) -> Self {
|
||||||
self.content = BlockContent::Text(RichText {
|
self.content = BlockContent::Text {
|
||||||
segments: vec![TextSegment {
|
text: RichText {
|
||||||
text: text.to_string(),
|
segments: vec![TextSegment {
|
||||||
annotations: TextAnnotations::default(),
|
text: text.to_string(),
|
||||||
link: None,
|
annotations: TextAnnotations::default(),
|
||||||
mention: None,
|
link: None,
|
||||||
}],
|
mention: None,
|
||||||
});
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_rich_text(mut self, rich_text: RichText) -> 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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ impl BlockBuilder {
|
||||||
self
|
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.properties.indent_level = level;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
@ -144,9 +144,9 @@ pub fn create_checklist(items: Vec<(&str, bool)>, created_by: Uuid) -> Block {
|
||||||
Block {
|
Block {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
block_type: BlockType::Checklist,
|
block_type: BlockType::Checklist,
|
||||||
content: BlockContent::Checklist(ChecklistContent {
|
content: BlockContent::Checklist {
|
||||||
items: checklist_items,
|
items: checklist_items,
|
||||||
}),
|
},
|
||||||
properties: BlockProperties::default(),
|
properties: BlockProperties::default(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
created_at: now,
|
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();
|
let now = Utc::now();
|
||||||
Block {
|
Block {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
block_type: BlockType::Toggle,
|
block_type: BlockType::Toggle,
|
||||||
content: BlockContent::Toggle(ToggleContent {
|
content: BlockContent::Toggle {
|
||||||
title: RichText {
|
title: RichText {
|
||||||
segments: vec![TextSegment {
|
segments: vec![TextSegment {
|
||||||
text: title.to_string(),
|
text: title.to_string(),
|
||||||
|
|
@ -170,9 +170,9 @@ pub fn create_toggle(title: &str, expanded: bool, children: Vec<Block>, created_
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
expanded,
|
expanded,
|
||||||
}),
|
},
|
||||||
properties: BlockProperties::default(),
|
properties: BlockProperties::default(),
|
||||||
children,
|
children: Vec::new(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
created_by,
|
created_by,
|
||||||
|
|
@ -190,11 +190,8 @@ pub fn create_callout(icon: &str, text: &str, background: &str, created_by: Uuid
|
||||||
Block {
|
Block {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
block_type: BlockType::Callout,
|
block_type: BlockType::Callout,
|
||||||
content: BlockContent::Callout(CalloutContent {
|
content: BlockContent::Callout {
|
||||||
icon: WorkspaceIcon {
|
icon: Some(icon.to_string()),
|
||||||
icon_type: super::IconType::Emoji,
|
|
||||||
value: icon.to_string(),
|
|
||||||
},
|
|
||||||
text: RichText {
|
text: RichText {
|
||||||
segments: vec![TextSegment {
|
segments: vec![TextSegment {
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
|
|
@ -203,9 +200,11 @@ pub fn create_callout(icon: &str, text: &str, background: &str, created_by: Uuid
|
||||||
mention: None,
|
mention: None,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
background_color: background.to_string(),
|
},
|
||||||
}),
|
properties: BlockProperties {
|
||||||
properties: BlockProperties::default(),
|
background_color: Some(background.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
|
@ -232,43 +231,10 @@ pub fn create_code(code: &str, language: &str, created_by: Uuid) -> Block {
|
||||||
Block {
|
Block {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
block_type: BlockType::Code,
|
block_type: BlockType::Code,
|
||||||
content: BlockContent::Code(CodeContent {
|
content: BlockContent::Code {
|
||||||
code: code.to_string(),
|
code: code.to_string(),
|
||||||
language: language.to_string(),
|
language: Some(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],
|
|
||||||
}),
|
|
||||||
properties: BlockProperties::default(),
|
properties: BlockProperties::default(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
|
|
@ -282,20 +248,10 @@ pub fn create_image(url: &str, caption: Option<&str>, created_by: Uuid) -> Block
|
||||||
Block {
|
Block {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
block_type: BlockType::Image,
|
block_type: BlockType::Image,
|
||||||
content: BlockContent::Media(MediaContent {
|
content: BlockContent::Media {
|
||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
caption: caption.map(|c| RichText {
|
caption: caption.map(|s| s.to_string()),
|
||||||
segments: vec![TextSegment {
|
},
|
||||||
text: c.to_string(),
|
|
||||||
annotations: TextAnnotations::default(),
|
|
||||||
link: None,
|
|
||||||
mention: None,
|
|
||||||
}],
|
|
||||||
}),
|
|
||||||
alt_text: None,
|
|
||||||
width: None,
|
|
||||||
height: None,
|
|
||||||
}),
|
|
||||||
properties: BlockProperties::default(),
|
properties: BlockProperties::default(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
created_at: now,
|
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();
|
let now = Utc::now();
|
||||||
Block {
|
Block {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
block_type: BlockType::Embed,
|
block_type: BlockType::Embed,
|
||||||
content: BlockContent::Embed(EmbedContent {
|
content: BlockContent::Embed {
|
||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
embed_type,
|
embed_type: Some(embed_type.to_string()),
|
||||||
caption: None,
|
},
|
||||||
}),
|
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(),
|
properties: BlockProperties::default(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
created_at: now,
|
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(
|
pub fn create_gb_component(
|
||||||
component_type: GbComponentType,
|
component_type: &str,
|
||||||
bot_id: Option<Uuid>,
|
config: serde_json::Value,
|
||||||
created_by: Uuid,
|
created_by: Uuid,
|
||||||
) -> Block {
|
) -> Block {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
Block {
|
Block {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
block_type: BlockType::GbComponent,
|
block_type: BlockType::GbComponent,
|
||||||
content: BlockContent::GbComponent(GbComponentContent {
|
content: BlockContent::GbComponent {
|
||||||
component_type,
|
component_type: component_type.to_string(),
|
||||||
bot_id,
|
config,
|
||||||
config: std::collections::HashMap::new(),
|
},
|
||||||
}),
|
|
||||||
properties: BlockProperties::default(),
|
properties: BlockProperties::default(),
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
|
|
@ -347,9 +344,9 @@ pub fn create_gb_component(
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct BlockOperation {
|
pub struct BlockOperation {
|
||||||
pub operation_type: BlockOperationType,
|
pub operation_type: BlockOperationType,
|
||||||
pub block_id: Uuid,
|
pub block_id: Option<Uuid>,
|
||||||
pub parent_id: Option<Uuid>,
|
pub parent_id: Option<Uuid>,
|
||||||
pub index: Option<usize>,
|
pub position: Option<usize>,
|
||||||
pub block: Option<Block>,
|
pub block: Option<Block>,
|
||||||
pub properties: Option<BlockProperties>,
|
pub properties: Option<BlockProperties>,
|
||||||
pub content: Option<BlockContent>,
|
pub content: Option<BlockContent>,
|
||||||
|
|
@ -362,181 +359,98 @@ pub enum BlockOperationType {
|
||||||
Update,
|
Update,
|
||||||
Delete,
|
Delete,
|
||||||
Move,
|
Move,
|
||||||
UpdateProperties,
|
Duplicate,
|
||||||
UpdateContent,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_block_operation(blocks: &mut Vec<Block>, operation: BlockOperation) -> Result<(), String> {
|
pub fn apply_block_operations(blocks: &mut Vec<Block>, operations: Vec<BlockOperation>) {
|
||||||
match operation.operation_type {
|
for op in operations {
|
||||||
BlockOperationType::Insert => {
|
match op.operation_type {
|
||||||
let block = operation.block.ok_or("Block required for insert")?;
|
BlockOperationType::Insert => {
|
||||||
let index = operation.index.unwrap_or(blocks.len());
|
if let Some(block) = op.block {
|
||||||
if index > blocks.len() {
|
let position = op.position.unwrap_or(blocks.len());
|
||||||
blocks.push(block);
|
if position <= blocks.len() {
|
||||||
} else {
|
blocks.insert(position, block);
|
||||||
blocks.insert(index, 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BlockOperationType::Delete => {
|
|
||||||
blocks.retain(|b| b.id != operation.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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
BlockOperationType::Update => {
|
||||||
if let BlockContent::Text(rt) = &block.content {
|
if let Some(block_id) = op.block_id {
|
||||||
result.push_str(&format!("{}{}\n", prefix, rich_text_to_string(rt)));
|
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 => {
|
||||||
|
if let Some(block_id) = op.block_id {
|
||||||
|
remove_block(blocks, block_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BlockOperationType::Move => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
fn find_block(blocks: &[Block], block_id: Uuid) -> Option<&Block> {
|
||||||
rich_text.segments.iter().map(|s| s.text.as_str()).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_block_by_id(blocks: &[Block], block_id: Uuid) -> Option<&Block> {
|
|
||||||
for block in blocks {
|
for block in blocks {
|
||||||
if block.id == block_id {
|
if block.id == block_id {
|
||||||
return Some(block);
|
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);
|
return Some(found);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find_block_by_id_mut(blocks: &mut [Block], block_id: Uuid) -> Option<&mut Block> {
|
fn find_block_mut(blocks: &mut [Block], block_id: Uuid) -> Option<&mut Block> {
|
||||||
for block in blocks {
|
for block in blocks.iter_mut() {
|
||||||
if block.id == block_id {
|
if block.id == block_id {
|
||||||
return Some(block);
|
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);
|
return Some(found);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
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
|
|
@ -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 {
|
for segment in &rich_text.segments {
|
||||||
stats.total_characters += segment.text.len();
|
stats.total_characters += segment.text.len();
|
||||||
stats.total_words += segment.text.split_whitespace().count();
|
stats.total_words += segment.text.split_whitespace().count();
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{
|
use super::{Block, Page, PagePermissions, WorkspaceIcon, WorkspaceSettings};
|
||||||
Block, Page, PagePermissions, Workspace, WorkspaceIcon, WorkspaceSettings,
|
|
||||||
blocks::{create_heading1, create_heading2, create_paragraph, create_checklist, create_callout, create_divider},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PageTemplate {
|
pub struct PageTemplate {
|
||||||
|
|
@ -101,703 +96,6 @@ pub struct PageStructure {
|
||||||
pub children: Vec<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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum TemplateError {
|
pub enum TemplateError {
|
||||||
TemplateNotFound,
|
TemplateNotFound,
|
||||||
|
|
@ -816,3 +114,109 @@ impl std::fmt::Display for TemplateError {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error 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
781
src/workspaces/ui.rs
Normal 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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
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()">×</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()">×</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()">×</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()">×</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))
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue