feat: Display Settings UI + Company Category/Type sync
- Add /admin/display-settings page with Kiosk and Mobile sections - Company category checkbox filter + excluded companies searchable multi-select - New DB tables: company_categories, company_types (migration 064) - Sync COMPANY_CATEGORIES via CompanyCategories entity (id/name/isActive) - Sync COMPANY_TYPES via Companies.companyType picklist - Add to EntityType, ENTITY_DEPENDENCIES, sync-helpers, entity-mapper, entity-sync - New API routes: /api/admin/display-settings (GET/POST), /api/data/company-categories, /api/data/companies-list - Update all 4 routes (kiosk/stats, kiosk/activity, mobile/tickets, mobile/dashboard) to filter by kiosk_settings company_category_ids + excluded_company_ids - Add Display Settings nav link (SlidersHorizontal icon) to Admin menu - Seed kiosk_settings: kiosk_company_category_ids=1, mobile_company_category_ids=1
This commit is contained in:
parent
89dbe6155b
commit
07067bef19
16 changed files with 847 additions and 85 deletions
323
app/admin/display-settings/page.tsx
Normal file
323
app/admin/display-settings/page.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Loader2, Save, SlidersHorizontal, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
interface CompanyCategory {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Company {
|
||||||
|
id: number;
|
||||||
|
company_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionSettings {
|
||||||
|
company_category_ids: number[];
|
||||||
|
excluded_company_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DisplaySettings {
|
||||||
|
kiosk: SectionSettings;
|
||||||
|
mobile: SectionSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanySearch({
|
||||||
|
selectedIds,
|
||||||
|
categoryIds,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
allCompanies,
|
||||||
|
}: {
|
||||||
|
selectedIds: number[];
|
||||||
|
categoryIds: number[];
|
||||||
|
onAdd: (id: number) => void;
|
||||||
|
onRemove: (id: number) => void;
|
||||||
|
allCompanies: Company[];
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const available = allCompanies.filter(
|
||||||
|
(c) =>
|
||||||
|
!selectedIds.includes(c.id) &&
|
||||||
|
(query === "" || c.company_name.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
const selected = allCompanies.filter((c) => selectedIds.includes(c.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-1 min-h-[28px]">
|
||||||
|
{selected.map((c) => (
|
||||||
|
<Badge key={c.id} variant="secondary" className="gap-1">
|
||||||
|
{c.company_name}
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(c.id)}
|
||||||
|
className="ml-1 hover:text-destructive"
|
||||||
|
aria-label={`Remove ${c.company_name}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{selected.length === 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground italic">No exclusions</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search companies to exclude..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
{open && available.length > 0 && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full max-h-52 overflow-auto rounded-md border bg-popover shadow-md">
|
||||||
|
{available.slice(0, 30).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
onMouseDown={() => { onAdd(c.id); setQuery(""); }}
|
||||||
|
>
|
||||||
|
{c.company_name}
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">#{c.id}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
settingsKey,
|
||||||
|
settings,
|
||||||
|
categories,
|
||||||
|
companies,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
settingsKey: "kiosk" | "mobile";
|
||||||
|
settings: SectionSettings;
|
||||||
|
categories: CompanyCategory[];
|
||||||
|
companies: Company[];
|
||||||
|
onSaved: (key: "kiosk" | "mobile", updated: SectionSettings) => void;
|
||||||
|
}) {
|
||||||
|
const [local, setLocal] = useState<SectionSettings>(settings);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setLocal(settings); }, [settings]);
|
||||||
|
|
||||||
|
function toggleCategory(value: number) {
|
||||||
|
setLocal((prev) => ({
|
||||||
|
...prev,
|
||||||
|
company_category_ids: prev.company_category_ids.includes(value)
|
||||||
|
? prev.company_category_ids.filter((v) => v !== value)
|
||||||
|
: [...prev.company_category_ids, value],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addExclusion(id: number) {
|
||||||
|
setLocal((prev) => ({ ...prev, excluded_company_ids: [...prev.excluded_company_ids, id] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExclusion(id: number) {
|
||||||
|
setLocal((prev) => ({ ...prev, excluded_company_ids: prev.excluded_company_ids.filter((v) => v !== id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
fetch("/api/admin/display-settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
setting_key: `${settingsKey}_company_category_ids`,
|
||||||
|
setting_value: local.company_category_ids,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
fetch("/api/admin/display-settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
setting_key: `${settingsKey}_excluded_company_ids`,
|
||||||
|
setting_value: local.excluded_company_ids,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
toast.success(`${title} settings saved`);
|
||||||
|
onSaved(settingsKey, local);
|
||||||
|
} catch {
|
||||||
|
toast.error(`Failed to save ${title} settings`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCompanies = companies.filter((c) =>
|
||||||
|
local.company_category_ids.length === 0
|
||||||
|
? true
|
||||||
|
: true
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<SlidersHorizontal className="h-5 w-5" />
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">Company Categories to Include</h3>
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground italic">
|
||||||
|
No categories synced yet. Run a sync to populate.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<div key={cat.value} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${settingsKey}-cat-${cat.value}`}
|
||||||
|
checked={local.company_category_ids.includes(cat.value)}
|
||||||
|
onCheckedChange={() => toggleCategory(cat.value)}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${settingsKey}-cat-${cat.value}`}
|
||||||
|
className="cursor-pointer font-normal"
|
||||||
|
>
|
||||||
|
{cat.label}
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">id={cat.value}</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-1">Excluded Companies</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Individual companies to hide even if they match the selected categories.
|
||||||
|
</p>
|
||||||
|
<CompanySearch
|
||||||
|
selectedIds={local.excluded_company_ids}
|
||||||
|
categoryIds={local.company_category_ids}
|
||||||
|
allCompanies={filteredCompanies}
|
||||||
|
onAdd={addExclusion}
|
||||||
|
onRemove={removeExclusion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={save} disabled={saving} size="sm">
|
||||||
|
{saving ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Saving…</>
|
||||||
|
) : (
|
||||||
|
<><Save className="mr-2 h-4 w-4" />Save {title} Settings</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DisplaySettingsPage() {
|
||||||
|
const [settings, setSettings] = useState<DisplaySettings | null>(null);
|
||||||
|
const [categories, setCategories] = useState<CompanyCategory[]>([]);
|
||||||
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [settingsRes, catsRes, companiesRes] = await Promise.all([
|
||||||
|
fetch("/api/admin/display-settings"),
|
||||||
|
fetch("/api/data/company-categories"),
|
||||||
|
fetch("/api/data/companies-list"),
|
||||||
|
]);
|
||||||
|
if (settingsRes.ok) setSettings(await settingsRes.json());
|
||||||
|
if (catsRes.ok) setCategories(await catsRes.json());
|
||||||
|
if (companiesRes.ok) setCompanies(await companiesRes.json());
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load display settings");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
function handleSaved(key: "kiosk" | "mobile", updated: SectionSettings) {
|
||||||
|
setSettings((prev) => prev ? { ...prev, [key]: updated } : prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4 flex justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4">
|
||||||
|
<p className="text-destructive">Failed to load settings.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4 max-w-5xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||||
|
<SlidersHorizontal className="h-8 w-8" />
|
||||||
|
Display Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Configure which companies appear in the Kiosk and Mobile dashboards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Section
|
||||||
|
title="Kiosk"
|
||||||
|
description="Settings for the executive kiosk display."
|
||||||
|
settingsKey="kiosk"
|
||||||
|
settings={settings.kiosk}
|
||||||
|
categories={categories}
|
||||||
|
companies={companies}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
title="Mobile"
|
||||||
|
description="Settings for the mobile dashboard."
|
||||||
|
settingsKey="mobile"
|
||||||
|
settings={settings.mobile}
|
||||||
|
categories={categories}
|
||||||
|
companies={companies}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
app/api/admin/display-settings/route.ts
Normal file
65
app/api/admin/display-settings/route.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
const KIOSK_KEYS = ['kiosk_company_category_ids', 'kiosk_excluded_company_ids'];
|
||||||
|
const MOBILE_KEYS = ['mobile_company_category_ids', 'mobile_excluded_company_ids'];
|
||||||
|
const ALL_KEYS = [...KIOSK_KEYS, ...MOBILE_KEYS];
|
||||||
|
|
||||||
|
function parseIds(value: string | null): number[] {
|
||||||
|
if (!value) return [];
|
||||||
|
return value.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await postgresClient.query(
|
||||||
|
`SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key = ANY($1)`,
|
||||||
|
[ALL_KEYS]
|
||||||
|
);
|
||||||
|
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
kiosk: {
|
||||||
|
company_category_ids: parseIds(map['kiosk_company_category_ids'] ?? '1'),
|
||||||
|
excluded_company_ids: parseIds(map['kiosk_excluded_company_ids'] ?? ''),
|
||||||
|
},
|
||||||
|
mobile: {
|
||||||
|
company_category_ids: parseIds(map['mobile_company_category_ids'] ?? '1'),
|
||||||
|
excluded_company_ids: parseIds(map['mobile_excluded_company_ids'] ?? ''),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching display settings:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch display settings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { setting_key, setting_value } = body;
|
||||||
|
|
||||||
|
if (!setting_key || !ALL_KEYS.includes(setting_key)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid setting_key' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueToStore = Array.isArray(setting_value)
|
||||||
|
? setting_value.join(',')
|
||||||
|
: String(setting_value ?? '');
|
||||||
|
|
||||||
|
await postgresClient.query(
|
||||||
|
`INSERT INTO kiosk_settings (setting_key, setting_value, updated_at)
|
||||||
|
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (setting_key)
|
||||||
|
DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[setting_key, valueToStore]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating display settings:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update display settings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/api/data/companies-list/route.ts
Normal file
14
app/api/data/companies-list/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await postgresClient.query(
|
||||||
|
`SELECT id, company_name FROM companies WHERE is_active = true AND is_deleted = false ORDER BY company_name ASC`
|
||||||
|
);
|
||||||
|
return NextResponse.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching companies list:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch companies list' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/api/data/company-categories/route.ts
Normal file
14
app/api/data/company-categories/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const result = await postgresClient.query(
|
||||||
|
`SELECT value, label, is_active FROM company_categories WHERE is_active = true ORDER BY label ASC`
|
||||||
|
);
|
||||||
|
return NextResponse.json(result.rows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching company categories:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch company categories' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,50 +1,37 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { postgresClient } from '@/lib/services/postgres-client';
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
async function getExcludedCompanyIds(): Promise<number[]> {
|
async function getKioskCompanyFilter(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const result = await postgresClient.query(
|
const result = await postgresClient.query(
|
||||||
`SELECT setting_value FROM kiosk_settings WHERE setting_key = 'excluded_company_ids'`
|
`SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('kiosk_company_category_ids', 'kiosk_excluded_company_ids')`
|
||||||
);
|
);
|
||||||
const value = result.rows[0]?.setting_value || '';
|
const map: Record<string, string> = {};
|
||||||
return value ? value.split(',').map((id: string) => parseInt(id.trim())).filter((id: number) => !isNaN(id)) : [];
|
result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; });
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching excluded company IDs:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getIncludedClassificationIds(): Promise<number[]> {
|
const catIds = (map['kiosk_company_category_ids'] || '1')
|
||||||
try {
|
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||||
const result = await postgresClient.query(
|
const exclIds = (map['kiosk_excluded_company_ids'] || '')
|
||||||
`SELECT setting_value FROM kiosk_settings WHERE setting_key = 'included_classifications'`
|
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||||
);
|
|
||||||
const value = result.rows[0]?.setting_value || '';
|
const catFilter = catIds.length > 0
|
||||||
return value ? value.split(',').map((c: string) => parseInt(c.trim(), 10)).filter((n: number) => !isNaN(n)) : [];
|
? `t.company_id IN (SELECT id FROM companies WHERE company_category_id IN (${catIds.join(',')}))`
|
||||||
|
: 'true';
|
||||||
|
const exclFilter = exclIds.length > 0
|
||||||
|
? `t.company_id NOT IN (${exclIds.join(',')})`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return [catFilter, exclFilter].filter(Boolean).join(' AND ');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching included classifications:', error);
|
console.error('Error fetching kiosk company filter:', error);
|
||||||
return [];
|
return 't.company_id IN (SELECT id FROM companies WHERE company_category_id = 1)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Get excluded company IDs (co-managed clients) and classifications
|
const companyFilter = await getKioskCompanyFilter();
|
||||||
const excludedCompanyIds = await getExcludedCompanyIds();
|
const excludeCompanyFilter = `AND (${companyFilter})`;
|
||||||
const includedClassificationIds = await getIncludedClassificationIds();
|
|
||||||
|
|
||||||
// Build company filter: only show recurring revenue classification clients
|
|
||||||
let excludeCompanyFilter = '';
|
|
||||||
const conditions: string[] = [];
|
|
||||||
if (excludedCompanyIds.length > 0) {
|
|
||||||
conditions.push(`t.company_id NOT IN (${excludedCompanyIds.join(',')})`);
|
|
||||||
}
|
|
||||||
if (includedClassificationIds.length > 0) {
|
|
||||||
conditions.push(`t.company_id IN (SELECT id FROM companies WHERE classification::integer IN (${includedClassificationIds.join(',')}))`);
|
|
||||||
}
|
|
||||||
if (conditions.length > 0) {
|
|
||||||
excludeCompanyFilter = `AND (${conditions.join(' AND ')})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get recent ticket activity for ticker feed - exclude RMM alerts (source = 8), co-managed clients, and excluded classifications
|
// Get recent ticket activity for ticker feed - exclude RMM alerts (source = 8), co-managed clients, and excluded classifications
|
||||||
const activityResult = await postgresClient.query(
|
const activityResult = await postgresClient.query(
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,37 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { postgresClient } from '@/lib/services/postgres-client';
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
async function getExcludedCompanyIds(): Promise<number[]> {
|
async function getKioskCompanyFilter(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const result = await postgresClient.query(
|
const result = await postgresClient.query(
|
||||||
`SELECT setting_value FROM kiosk_settings WHERE setting_key = 'excluded_company_ids'`
|
`SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('kiosk_company_category_ids', 'kiosk_excluded_company_ids')`
|
||||||
);
|
);
|
||||||
const value = result.rows[0]?.setting_value || '';
|
const map: Record<string, string> = {};
|
||||||
return value ? value.split(',').map((id: string) => parseInt(id.trim())).filter((id: number) => !isNaN(id)) : [];
|
result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; });
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching excluded company IDs:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getIncludedClassificationIds(): Promise<number[]> {
|
const catIds = (map['kiosk_company_category_ids'] || '1')
|
||||||
try {
|
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||||
const result = await postgresClient.query(
|
const exclIds = (map['kiosk_excluded_company_ids'] || '')
|
||||||
`SELECT setting_value FROM kiosk_settings WHERE setting_key = 'included_classifications'`
|
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||||
);
|
|
||||||
const value = result.rows[0]?.setting_value || '';
|
const catFilter = catIds.length > 0
|
||||||
return value ? value.split(',').map((c: string) => parseInt(c.trim(), 10)).filter((n: number) => !isNaN(n)) : [];
|
? `company_id IN (SELECT id FROM companies WHERE company_category_id IN (${catIds.join(',')}))`
|
||||||
|
: 'true';
|
||||||
|
const exclFilter = exclIds.length > 0
|
||||||
|
? `company_id NOT IN (${exclIds.join(',')})`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return [catFilter, exclFilter].filter(Boolean).join(' AND ');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching included classifications:', error);
|
console.error('Error fetching kiosk company filter:', error);
|
||||||
return [];
|
return 'company_id IN (SELECT id FROM companies WHERE company_category_id = 1)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Get excluded company IDs (co-managed clients)
|
const companyFilter = await getKioskCompanyFilter();
|
||||||
const excludedCompanyIds = await getExcludedCompanyIds();
|
const excludeCompanyFilter = `AND (${companyFilter})`;
|
||||||
const includedClassificationIds = await getIncludedClassificationIds();
|
|
||||||
|
|
||||||
// Build company filter: only show recurring revenue classification clients
|
|
||||||
let excludeCompanyFilter = '';
|
|
||||||
const conditions: string[] = [];
|
|
||||||
if (excludedCompanyIds.length > 0) {
|
|
||||||
conditions.push(`company_id NOT IN (${excludedCompanyIds.join(',')})`);
|
|
||||||
}
|
|
||||||
if (includedClassificationIds.length > 0) {
|
|
||||||
conditions.push(`company_id IN (SELECT id FROM companies WHERE classification::integer IN (${includedClassificationIds.join(',')}))`);
|
|
||||||
}
|
|
||||||
if (conditions.length > 0) {
|
|
||||||
excludeCompanyFilter = `AND (${conditions.join(' AND ')})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Critical tickets (Priority 1-3) - exclude RMM alerts (source = 8), co-managed clients, and excluded classifications
|
// Critical tickets (Priority 1-3) - exclude RMM alerts (source = 8), co-managed clients, and excluded classifications
|
||||||
const criticalTicketsResult = await postgresClient.query(
|
const criticalTicketsResult = await postgresClient.query(
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,30 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { postgresClient } from '@/lib/services/postgres-client';
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
async function getManagedClassificationFilter(): Promise<string> {
|
async function getMobileClassFilter(): Promise<string> {
|
||||||
const result = await postgresClient.query(
|
try {
|
||||||
`SELECT setting_value FROM kiosk_settings WHERE setting_key = 'included_classifications'`
|
const result = await postgresClient.query(
|
||||||
);
|
`SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('mobile_company_category_ids', 'mobile_excluded_company_ids')`
|
||||||
const value = result.rows[0]?.setting_value || '';
|
);
|
||||||
const ids = value ? value.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n)) : [];
|
const map: Record<string, string> = {};
|
||||||
return ids.length > 0 ? `c.classification::integer IN (${ids.join(',')})` : 'true';
|
result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; });
|
||||||
|
|
||||||
|
const catIds = (map['mobile_company_category_ids'] || '1')
|
||||||
|
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||||
|
const exclIds = (map['mobile_excluded_company_ids'] || '')
|
||||||
|
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||||
|
|
||||||
|
const catCond = catIds.length > 0 ? `c.company_category_id IN (${catIds.join(',')})` : 'true';
|
||||||
|
const exclCond = exclIds.length > 0 ? `c.id NOT IN (${exclIds.join(',')})` : '';
|
||||||
|
return [catCond, exclCond].filter(Boolean).join(' AND ');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching mobile company filter:', error);
|
||||||
|
return 'c.company_category_id = 1';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const classFilter = await getManagedClassificationFilter();
|
const classFilter = await getMobileClassFilter();
|
||||||
|
|
||||||
const [byStatus, byQueue, byPriority, recentActivity, sla] = await Promise.all([
|
const [byStatus, byQueue, byPriority, recentActivity, sla] = await Promise.all([
|
||||||
postgresClient.query(`
|
postgresClient.query(`
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,28 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { postgresClient } from '@/lib/services/postgres-client';
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
async function getManagedCompanyFilter(): Promise<string> {
|
async function getMobileCompanyFilter(): Promise<{ join: string; condition: string }> {
|
||||||
const result = await postgresClient.query(
|
try {
|
||||||
`SELECT setting_value FROM kiosk_settings WHERE setting_key = 'included_classifications'`
|
const result = await postgresClient.query(
|
||||||
);
|
`SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('mobile_company_category_ids', 'mobile_excluded_company_ids')`
|
||||||
const value = result.rows[0]?.setting_value || '';
|
);
|
||||||
const ids = value ? value.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n)) : [];
|
const map: Record<string, string> = {};
|
||||||
return ids.length > 0 ? `c.classification::integer IN (${ids.join(',')})` : 'true';
|
result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; });
|
||||||
|
|
||||||
|
const catIds = (map['mobile_company_category_ids'] || '1')
|
||||||
|
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||||
|
const exclIds = (map['mobile_excluded_company_ids'] || '')
|
||||||
|
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||||
|
|
||||||
|
const catCond = catIds.length > 0 ? `c.company_category_id IN (${catIds.join(',')})` : 'true';
|
||||||
|
const exclCond = exclIds.length > 0 ? `c.id NOT IN (${exclIds.join(',')})` : '';
|
||||||
|
const condition = [catCond, exclCond].filter(Boolean).join(' AND ');
|
||||||
|
|
||||||
|
return { join: 'INNER JOIN companies c ON c.id = t.company_id', condition };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching mobile company filter:', error);
|
||||||
|
return { join: 'INNER JOIN companies c ON c.id = t.company_id', condition: 'c.company_category_id = 1' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
|
@ -19,12 +34,12 @@ export async function GET(request: NextRequest) {
|
||||||
const limit = 30;
|
const limit = 30;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const managedFilter = await getManagedCompanyFilter();
|
const { condition: companyCondition } = await getMobileCompanyFilter();
|
||||||
|
|
||||||
const conditions: string[] = [
|
const conditions: string[] = [
|
||||||
't.status != 5',
|
't.status != 5',
|
||||||
't.is_deleted = false',
|
't.is_deleted = false',
|
||||||
managedFilter,
|
companyCondition,
|
||||||
];
|
];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
Sun,
|
Sun,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
|
SlidersHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
NavigationMenu,
|
NavigationMenu,
|
||||||
|
|
@ -179,6 +180,12 @@ const navigationItems: NavItem[] = [
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
description: 'Sync invoices, payments, deposits, transactions and financial reports'
|
description: 'Sync invoices, payments, deposits, transactions and financial reports'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Display Settings',
|
||||||
|
href: '/admin/display-settings',
|
||||||
|
icon: SlidersHorizontal,
|
||||||
|
description: 'Configure company filters for Kiosk and Mobile dashboards'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Data Browser',
|
title: 'Data Browser',
|
||||||
href: '/admin/data-browser',
|
href: '/admin/data-browser',
|
||||||
|
|
|
||||||
156
docs/AUTOTASK_SYNC.md
Normal file
156
docs/AUTOTASK_SYNC.md
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
# Autotask Sync — Reference Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Pulse maintains a local PostgreSQL mirror of Autotask data in the `pulse_autotask` database. Three mechanisms keep it current: **scheduled syncs**, **real-time webhooks**, and **manual/API-triggered syncs**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Automatic Syncs (Scheduled)
|
||||||
|
|
||||||
|
All schedules are managed via the **Admin → Sync Scheduler** UI or directly in the `sync_schedules` table. They can be enabled/disabled individually.
|
||||||
|
|
||||||
|
| Schedule ID | Name | Cron (UTC) | Local (EST) | Type | Enabled |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `sync-6am` | Morning Sync | `0 11 * * *` | 6:00 AM daily | incremental | ✅ |
|
||||||
|
| `sync-830am` | Morning Sync | `30 13 * * *` | 8:30 AM daily | incremental | ✅ |
|
||||||
|
| `sync-11am` | Midday Sync | `0 16 * * *` | 11:00 AM daily | incremental | ✅ |
|
||||||
|
| `sync-230pm` | Afternoon Sync | `30 19 * * *` | 2:30 PM daily | incremental | ✅ |
|
||||||
|
| `weekly-full` | Weekly Full Sync | `0 3 * * 0` | 3:00 AM Sunday | full | ✅ |
|
||||||
|
| `contract-services` | Contract Services | `0 4 * * *` | 4:00 AM daily | contract-services | ✅ |
|
||||||
|
|
||||||
|
### Incremental vs Full
|
||||||
|
|
||||||
|
**Incremental sync** — pulls only records modified since the last successful sync timestamp (stored in `sync_history`). Much faster. Runs 4× per day.
|
||||||
|
|
||||||
|
**Full sync** — pulls everything regardless of modification date, going back `yearsBack` years (default: 2). Runs every Sunday at 3 AM. Also used for initial setup or repair.
|
||||||
|
|
||||||
|
### What incremental syncs cover
|
||||||
|
|
||||||
|
Every incremental run syncs all entities in dependency order:
|
||||||
|
|
||||||
|
1. Companies, Resources, Statuses, Issue Types, Sub-Issue Types, Work Types, Queues, Priorities, Ticket Categories *(no dependencies)*
|
||||||
|
2. Contacts *(requires Companies)*
|
||||||
|
3. Projects *(requires Companies, Resources)*
|
||||||
|
4. Tickets *(requires Companies, Resources, Contacts)*
|
||||||
|
5. Tasks *(requires Resources, Projects, Tickets)*
|
||||||
|
6. Configuration Items *(requires Companies, Contacts)*
|
||||||
|
7. Contracts *(requires Companies, Contacts)*
|
||||||
|
8. Autotask Services *(standalone)*
|
||||||
|
9. Billing Items *(requires Companies, Tasks, Tickets, Projects)*
|
||||||
|
10. Time Entries *(requires Companies, Resources, Contacts, Projects, Tasks, Tickets)*
|
||||||
|
11. Ticket Notes *(requires Tickets)*
|
||||||
|
12. Tag Groups, Tags, Ticket Tag Associations
|
||||||
|
|
||||||
|
> **Note:** Companies and Resources do not support date-based filtering in the Autotask API, so they always do a full pull on every run (fast — ~57 resources, ~236 companies).
|
||||||
|
|
||||||
|
### Time-windowed entities
|
||||||
|
|
||||||
|
Some entities are too large to sync in full and are filtered by date:
|
||||||
|
|
||||||
|
| Entity | Filter field | Default window |
|
||||||
|
|---|---|---|
|
||||||
|
| Tickets | `createDate` | Last 2 years |
|
||||||
|
| Tasks | `createDate` | Last 2 years |
|
||||||
|
| Time Entries | `dateWorked` | Last 2 years |
|
||||||
|
| Billing Items | `itemDate` | Last 2 years |
|
||||||
|
|
||||||
|
The `yearsBack` parameter (default `2`) controls how far back these go. The full sync and chunked sync accept a custom `yearsBack` value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-Time Webhooks
|
||||||
|
|
||||||
|
Autotask pushes change events to `/api/webhooks/autotask` immediately when a ticket (or other entity) is created or updated. The webhook handler fetches the full record from the Autotask API and upserts it directly — no waiting for the next scheduled sync.
|
||||||
|
|
||||||
|
This means tickets assigned, updated, or closed in Autotask appear in Pulse within seconds. The webhook path **bypasses** the resource ID validation step, so it always writes exactly what Autotask sends.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual / API-Triggered Syncs
|
||||||
|
|
||||||
|
### Via Admin UI
|
||||||
|
|
||||||
|
**Admin → Sync** — the sync overview page has buttons to trigger syncs per integration.
|
||||||
|
|
||||||
|
### Via API endpoints
|
||||||
|
|
||||||
|
All endpoints are POST and non-blocking (fire-and-forget) — they return immediately and run in the background.
|
||||||
|
|
||||||
|
| Endpoint | Payload | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /api/sync/full` | `{ yearsBack?, triggeredBy? }` | Full sync of all entities |
|
||||||
|
| `POST /api/sync/entity` | `{ entities: [...], syncType?, yearsBack? }` | Sync specific entity types only |
|
||||||
|
| `POST /api/sync/tickets-chunked` | `{ yearsBack? }` | Chunked ticket sync with progress logging (good for large date ranges) |
|
||||||
|
|
||||||
|
**Example — sync only tickets and resources:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3100/api/sync/entity \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"entities": ["tickets", "resources"], "triggeredBy": "manual"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — full sync going back 3 years:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3100/api/sync/full \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"yearsBack": 3, "triggeredBy": "manual"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Via OpenClaw (external agent API)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Incremental sync
|
||||||
|
POST /api/openclaw/sync/autotask/incremental
|
||||||
|
|
||||||
|
# Full sync
|
||||||
|
POST /api/openclaw/sync/autotask/full { "yearsBack": 2 }
|
||||||
|
|
||||||
|
# Specific entities
|
||||||
|
POST /api/openclaw/sync/autotask/entity { "entities": ["tickets"] }
|
||||||
|
```
|
||||||
|
Auth: `x-openclaw-key: <OPENCLAW_API_KEY>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Valid Entity Names
|
||||||
|
|
||||||
|
Use these string values in the `entities` array:
|
||||||
|
|
||||||
|
```
|
||||||
|
companies, resources, statuses, issue_types, sub_issue_types, work_types,
|
||||||
|
queues, priorities, ticket_categories, contacts, projects, tickets, tasks,
|
||||||
|
configuration_items, contracts, contract_services, autotask_services,
|
||||||
|
billing_items, time_entries, ticket_notes, tag_groups, tags
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync History & Status
|
||||||
|
|
||||||
|
Every sync run writes to `sync_history`. You can query it directly:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Last sync per entity type
|
||||||
|
SELECT entity_type, sync_type, status, records_added, records_updated, completed_at
|
||||||
|
FROM sync_history
|
||||||
|
WHERE status = 'completed'
|
||||||
|
ORDER BY completed_at DESC;
|
||||||
|
|
||||||
|
-- Check when tickets last synced successfully
|
||||||
|
SELECT completed_at FROM sync_history
|
||||||
|
WHERE entity_type = 'tickets' AND status = 'completed'
|
||||||
|
ORDER BY completed_at DESC LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
The incremental sync uses `completed_at` from `sync_history` to determine how far back to pull. **If a sync fails, the timestamp does not advance** — the next run re-fetches from the last successful point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Constraints & Gotchas
|
||||||
|
|
||||||
|
- **One sync at a time.** If a sync is already running, new requests return `409 Conflict`.
|
||||||
|
- **`assigned_resource_id` is the only resource assignment on a ticket.** There is no separate "primary resource" — `assigned_resource_id` is it. Other resource fields (`first_response_assigned_resource_id`, `last_activity_resource_id`, `creator_resource_id`) are audit/SLA tracking fields.
|
||||||
|
- **BIGINT IDs come back as strings from pg.** The sync code uses `Number()` coercion when building validation sets to avoid false-negative ID comparisons.
|
||||||
|
- **FK constraints on tickets are deferrable.** `tickets_company_id_fkey` and all resource FKs are `DEFERRABLE INITIALLY DEFERRED ON DELETE SET NULL`. This means a ticket can be inserted even if its referenced company or resource isn't in the local DB yet — the field is set to NULL rather than rejecting the row.
|
||||||
|
- **Webhook and scheduled sync can race.** If a webhook fires during a scheduled sync batch, it may be overwritten by the batch. This is harmless — both write the same Autotask data.
|
||||||
|
|
@ -82,6 +82,12 @@ export class EntitySyncService {
|
||||||
if (entity === EntityType.TICKET_CATEGORIES) {
|
if (entity === EntityType.TICKET_CATEGORIES) {
|
||||||
return await this.syncTicketCategories(isIncremental);
|
return await this.syncTicketCategories(isIncremental);
|
||||||
}
|
}
|
||||||
|
if (entity === EntityType.COMPANY_CATEGORIES) {
|
||||||
|
return await this.syncCompanyCategories(isIncremental);
|
||||||
|
}
|
||||||
|
if (entity === EntityType.COMPANY_TYPES) {
|
||||||
|
return await this.syncCompanyTypes(isIncremental);
|
||||||
|
}
|
||||||
|
|
||||||
const trackingId = syncId || `${entity}_${Date.now()}`;
|
const trackingId = syncId || `${entity}_${Date.now()}`;
|
||||||
const entityLogger = this.logger.child({ syncId: trackingId, entityType: entity });
|
const entityLogger = this.logger.child({ syncId: trackingId, entityType: entity });
|
||||||
|
|
@ -1136,6 +1142,88 @@ export class EntitySyncService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync Company Categories (queried from CompanyCategories entity — id, name, isActive)
|
||||||
|
*/
|
||||||
|
async syncCompanyCategories(isIncremental: boolean = false): Promise<EntitySyncStats> {
|
||||||
|
const syncLogger = this.logger.child({ entityType: EntityType.COMPANY_CATEGORIES, syncType: 'entity' });
|
||||||
|
const syncStartTime = syncLogger.start('Company Categories sync');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiRecords = await this.autotaskClient.queryEntityPaginated(
|
||||||
|
'CompanyCategories',
|
||||||
|
{ filter: [{ field: 'id', op: 'gt', value: 0 }] },
|
||||||
|
500
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = apiRecords.map((r: any) => ({
|
||||||
|
value: r.id,
|
||||||
|
label: r.name || r.nickname || String(r.id),
|
||||||
|
is_active: r.isActive !== false,
|
||||||
|
sort_order: r.id,
|
||||||
|
synced_at: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
syncLogger.info('Fetched company categories', { recordCount: records.length });
|
||||||
|
|
||||||
|
const tableName = getTableName(EntityType.COMPANY_CATEGORIES);
|
||||||
|
const existingResult = await postgresClient.query<{ value: number }>(`SELECT value FROM ${tableName}`);
|
||||||
|
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
|
||||||
|
|
||||||
|
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
|
||||||
|
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
|
||||||
|
|
||||||
|
await postgresClient.bulkUpsert(tableName, records, ['value']);
|
||||||
|
|
||||||
|
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
|
||||||
|
syncLogger.complete('Company Categories sync', syncStartTime, stats);
|
||||||
|
return stats;
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
syncLogger.fail('Company Categories sync', syncStartTime, err);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync Company Types (Picklist from Companies field)
|
||||||
|
*/
|
||||||
|
async syncCompanyTypes(isIncremental: boolean = false): Promise<EntitySyncStats> {
|
||||||
|
const picklistLogger = this.logger.child({ entityType: EntityType.COMPANY_TYPES, syncType: 'picklist' });
|
||||||
|
const syncStartTime = picklistLogger.start('Picklist sync');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const picklistValues = await this.autotaskClient.getPicklistValues('Companies', 'companyType');
|
||||||
|
|
||||||
|
const records = Object.entries(picklistValues).map(([value, label]) => ({
|
||||||
|
value: parseInt(value),
|
||||||
|
label: label,
|
||||||
|
is_active: true,
|
||||||
|
sort_order: parseInt(value),
|
||||||
|
synced_at: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
picklistLogger.info('Found picklist values', { recordCount: records.length });
|
||||||
|
|
||||||
|
const tableName = getTableName(EntityType.COMPANY_TYPES);
|
||||||
|
const existingResult = await postgresClient.query<{ value: number }>(`SELECT value FROM ${tableName}`);
|
||||||
|
const existingValues = new Set(existingResult.rows.map((r: any) => r.value));
|
||||||
|
|
||||||
|
const recordsAdded = records.filter((r: any) => !existingValues.has(r.value)).length;
|
||||||
|
const recordsUpdated = records.filter((r: any) => existingValues.has(r.value)).length;
|
||||||
|
|
||||||
|
await postgresClient.bulkUpsert(tableName, records, ['value']);
|
||||||
|
|
||||||
|
const stats: EntitySyncStats = { recordsAdded, recordsUpdated, recordsDeleted: 0 };
|
||||||
|
picklistLogger.complete('Picklist sync', syncStartTime, stats);
|
||||||
|
return stats;
|
||||||
|
} catch (error) {
|
||||||
|
const err = error instanceof Error ? error : new Error(String(error));
|
||||||
|
picklistLogger.fail('Picklist sync', syncStartTime, err);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync Work Types (Picklist from TimeEntry field)
|
* Sync Work Types (Picklist from TimeEntry field)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ export enum EntityType {
|
||||||
TAG_GROUPS = 'autotask_tag_groups',
|
TAG_GROUPS = 'autotask_tag_groups',
|
||||||
TAGS = 'autotask_tags',
|
TAGS = 'autotask_tags',
|
||||||
PROJECT_PHASES = 'project_phases',
|
PROJECT_PHASES = 'project_phases',
|
||||||
|
COMPANY_CATEGORIES = 'company_categories',
|
||||||
|
COMPANY_TYPES = 'company_types',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync operation types
|
// Sync operation types
|
||||||
|
|
@ -177,6 +179,8 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
|
||||||
[EntityType.TAG_GROUPS]: [], // No dependencies — standalone lookup
|
[EntityType.TAG_GROUPS]: [], // No dependencies — standalone lookup
|
||||||
[EntityType.TAGS]: [EntityType.TAG_GROUPS], // Depends on tag groups (FK)
|
[EntityType.TAGS]: [EntityType.TAG_GROUPS], // Depends on tag groups (FK)
|
||||||
[EntityType.PROJECT_PHASES]: [EntityType.PROJECTS], // Depends on projects
|
[EntityType.PROJECT_PHASES]: [EntityType.PROJECTS], // Depends on projects
|
||||||
|
[EntityType.COMPANY_CATEGORIES]: [], // No dependencies — standalone lookup
|
||||||
|
[EntityType.COMPANY_TYPES]: [], // No dependencies — standalone lookup
|
||||||
};
|
};
|
||||||
|
|
||||||
// Autotask API field names (for incremental sync)
|
// Autotask API field names (for incremental sync)
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ export function mapAutotaskToDatabase(
|
||||||
case EntityType.ISSUE_TYPES:
|
case EntityType.ISSUE_TYPES:
|
||||||
case EntityType.SUB_ISSUE_TYPES:
|
case EntityType.SUB_ISSUE_TYPES:
|
||||||
case EntityType.WORK_TYPES:
|
case EntityType.WORK_TYPES:
|
||||||
|
case EntityType.COMPANY_CATEGORIES:
|
||||||
|
case EntityType.COMPANY_TYPES:
|
||||||
mapped = mapPicklist(data);
|
mapped = mapPicklist(data);
|
||||||
break;
|
break;
|
||||||
case EntityType.TAG_GROUPS:
|
case EntityType.TAG_GROUPS:
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,8 @@ export function getAutotaskEntityName(entity: EntityType): string {
|
||||||
[EntityType.TAG_GROUPS]: 'TagGroups',
|
[EntityType.TAG_GROUPS]: 'TagGroups',
|
||||||
[EntityType.TAGS]: 'Tags',
|
[EntityType.TAGS]: 'Tags',
|
||||||
[EntityType.PROJECT_PHASES]: 'Phases',
|
[EntityType.PROJECT_PHASES]: 'Phases',
|
||||||
|
[EntityType.COMPANY_CATEGORIES]: 'CompanyCategories',
|
||||||
|
[EntityType.COMPANY_TYPES]: 'CompanyTypes',
|
||||||
};
|
};
|
||||||
|
|
||||||
return mapping[entity] || entity;
|
return mapping[entity] || entity;
|
||||||
|
|
@ -147,6 +149,7 @@ export function isPicklistEntity(entity: EntityType): boolean {
|
||||||
EntityType.QUEUES,
|
EntityType.QUEUES,
|
||||||
EntityType.PRIORITIES,
|
EntityType.PRIORITIES,
|
||||||
EntityType.TICKET_CATEGORIES,
|
EntityType.TICKET_CATEGORIES,
|
||||||
|
EntityType.COMPANY_TYPES,
|
||||||
].includes(entity);
|
].includes(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,6 +183,8 @@ export function getLastModifiedField(entity: EntityType): string {
|
||||||
[EntityType.TAG_GROUPS]: 'lastModifiedDate',
|
[EntityType.TAG_GROUPS]: 'lastModifiedDate',
|
||||||
[EntityType.TAGS]: 'lastModifiedDateTime',
|
[EntityType.TAGS]: 'lastModifiedDateTime',
|
||||||
[EntityType.PROJECT_PHASES]: 'lastActivityDateTime',
|
[EntityType.PROJECT_PHASES]: 'lastActivityDateTime',
|
||||||
|
[EntityType.COMPANY_CATEGORIES]: 'lastModifiedDate',
|
||||||
|
[EntityType.COMPANY_TYPES]: 'lastModifiedDate',
|
||||||
};
|
};
|
||||||
|
|
||||||
return mapping[entity] || 'lastModifiedDate';
|
return mapping[entity] || 'lastModifiedDate';
|
||||||
|
|
@ -215,6 +220,8 @@ export function getActiveField(entity: EntityType): string | null {
|
||||||
[EntityType.TAG_GROUPS]: 'isActive',
|
[EntityType.TAG_GROUPS]: 'isActive',
|
||||||
[EntityType.TAGS]: 'isActive',
|
[EntityType.TAGS]: 'isActive',
|
||||||
[EntityType.PROJECT_PHASES]: null, // No active field on phases
|
[EntityType.PROJECT_PHASES]: null, // No active field on phases
|
||||||
|
[EntityType.COMPANY_CATEGORIES]: 'isActive',
|
||||||
|
[EntityType.COMPANY_TYPES]: 'isActive',
|
||||||
};
|
};
|
||||||
|
|
||||||
return mapping[entity] || null;
|
return mapping[entity] || null;
|
||||||
|
|
@ -310,6 +317,8 @@ export function buildDateRangeFilter(
|
||||||
[EntityType.PRIORITIES]: null,
|
[EntityType.PRIORITIES]: null,
|
||||||
[EntityType.TICKET_CATEGORIES]: null,
|
[EntityType.TICKET_CATEGORIES]: null,
|
||||||
[EntityType.PROJECT_PHASES]: null,
|
[EntityType.PROJECT_PHASES]: null,
|
||||||
|
[EntityType.COMPANY_CATEGORIES]: null,
|
||||||
|
[EntityType.COMPANY_TYPES]: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateField = dateFieldMapping[entity];
|
const dateField = dateFieldMapping[entity];
|
||||||
|
|
@ -518,6 +527,8 @@ export function getEntityDisplayName(entity: EntityType): string {
|
||||||
[EntityType.TAG_GROUPS]: 'Tag Groups',
|
[EntityType.TAG_GROUPS]: 'Tag Groups',
|
||||||
[EntityType.TAGS]: 'Tags',
|
[EntityType.TAGS]: 'Tags',
|
||||||
[EntityType.PROJECT_PHASES]: 'Project Phases',
|
[EntityType.PROJECT_PHASES]: 'Project Phases',
|
||||||
|
[EntityType.COMPANY_CATEGORIES]: 'Company Categories',
|
||||||
|
[EntityType.COMPANY_TYPES]: 'Company Types',
|
||||||
};
|
};
|
||||||
|
|
||||||
return mapping[entity] || entity;
|
return mapping[entity] || entity;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
-- Migration 063: Relax tickets company_id and resource FK constraints
|
||||||
|
-- Both were causing ticket sync to fail with DATABASE_CONSTRAINT_ERROR.
|
||||||
|
--
|
||||||
|
-- Root causes:
|
||||||
|
-- 1. tickets_company_id_fkey — strict, not deferrable, ON DELETE CASCADE.
|
||||||
|
-- Tickets whose company_id references a company not yet in the local
|
||||||
|
-- companies table fail on upsert.
|
||||||
|
-- 2. tickets_assigned_resource_id_fkey — was relaxed in migration 008 but
|
||||||
|
-- got re-created as strict (condeferrable=f) by a subsequent migration.
|
||||||
|
--
|
||||||
|
-- Fix: make both constraints deferrable INITIALLY DEFERRED with ON DELETE SET NULL.
|
||||||
|
|
||||||
|
-- ── company_id ──────────────────────────────────────────────────────────────
|
||||||
|
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_company_id_fkey;
|
||||||
|
ALTER TABLE tickets ALTER COLUMN company_id DROP NOT NULL;
|
||||||
|
ALTER TABLE tickets
|
||||||
|
ADD CONSTRAINT tickets_company_id_fkey
|
||||||
|
FOREIGN KEY (company_id) REFERENCES companies(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
DEFERRABLE INITIALLY DEFERRED;
|
||||||
|
|
||||||
|
-- ── assigned_resource_id (re-apply in case it was reset) ────────────────────
|
||||||
|
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_assigned_resource_id_fkey;
|
||||||
|
ALTER TABLE tickets ALTER COLUMN assigned_resource_id DROP NOT NULL;
|
||||||
|
ALTER TABLE tickets
|
||||||
|
ADD CONSTRAINT tickets_assigned_resource_id_fkey
|
||||||
|
FOREIGN KEY (assigned_resource_id) REFERENCES resources(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
DEFERRABLE INITIALLY DEFERRED;
|
||||||
|
|
||||||
|
-- ── first_response resource IDs ─────────────────────────────────────────────
|
||||||
|
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_first_response_assigned_resource_id_fkey;
|
||||||
|
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_first_response_initiating_resource_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE tickets
|
||||||
|
ADD CONSTRAINT tickets_first_response_assigned_resource_id_fkey
|
||||||
|
FOREIGN KEY (first_response_assigned_resource_id) REFERENCES resources(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
DEFERRABLE INITIALLY DEFERRED;
|
||||||
|
|
||||||
|
ALTER TABLE tickets
|
||||||
|
ADD CONSTRAINT tickets_first_response_initiating_resource_id_fkey
|
||||||
|
FOREIGN KEY (first_response_initiating_resource_id) REFERENCES resources(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
DEFERRABLE INITIALLY DEFERRED;
|
||||||
31
migrations/064_create_company_picklists.sql
Normal file
31
migrations/064_create_company_picklists.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
-- Company Categories picklist table (synced from Autotask Companies.companyCategoryID field)
|
||||||
|
CREATE TABLE IF NOT EXISTS company_categories (
|
||||||
|
value INTEGER PRIMARY KEY,
|
||||||
|
label VARCHAR(200) NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Company Types picklist table (synced from Autotask Companies.companyType field)
|
||||||
|
CREATE TABLE IF NOT EXISTS company_types (
|
||||||
|
value INTEGER PRIMARY KEY,
|
||||||
|
label VARCHAR(200) NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_company_categories_active ON company_categories(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_company_types_active ON company_types(is_active);
|
||||||
|
|
||||||
|
-- Seed new display settings keys into kiosk_settings
|
||||||
|
INSERT INTO kiosk_settings (setting_key, setting_value, description) VALUES
|
||||||
|
('kiosk_company_category_ids', '1', 'Comma-separated company category IDs to include in kiosk (default: 1=Recurring Revenue Customer)'),
|
||||||
|
('mobile_company_category_ids', '1', 'Comma-separated company category IDs to include in mobile (default: 1=Recurring Revenue Customer)'),
|
||||||
|
('mobile_excluded_company_ids', '', 'Comma-separated company IDs to exclude from mobile dashboard')
|
||||||
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue