feat: Mimecast multi-tenant held mail viewer
- migration 062: mimecast_tenants table (company_id, client_id/secret, account_code) - Seed Wulf (CUSA13A95) + Seubert (CUSA96A181) tenants - MimecastClient.getHeldMessages(): full pagination via meta.pagination.next cursor (API always returns 10/page regardless of pageSize param, totalCount in meta) - getMimecastClientForTenant() factory for per-tenant instantiation - GET /api/mimecast/held?tenantId=&recipient= — fetches all tenants in parallel, merges + sorts by date, returns per-tenant counts + combined messages[] - Held Mail tab on /admin/sync/mimecast (on-demand load, recipient filter, tenant badges, policy filter dropdown, DMARC/impersonation highlighted red)
This commit is contained in:
parent
a98c0daf15
commit
fcdec8e38b
12 changed files with 2208 additions and 27 deletions
1074
.firecrawl/mimecast-hold-docs.md
Normal file
1074
.firecrawl/mimecast-hold-docs.md
Normal file
File diff suppressed because it is too large
Load diff
1
.firecrawl/mimecast-hold.json
Normal file
1
.firecrawl/mimecast-hold.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -7,7 +7,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||
import {
|
||||
ArrowLeft, Activity, History, Calendar, Mail, RefreshCw, Loader2,
|
||||
CheckCircle2, XCircle, AlertTriangle, Shield, Inbox, Send,
|
||||
Clock, ChevronDown, ChevronRight,
|
||||
Clock, ChevronDown, ChevronRight, Users, Search, LockKeyhole, UnlockKeyhole,
|
||||
PauseCircle, Building2,
|
||||
} from 'lucide-react';
|
||||
import SyncScheduler from '@/components/admin/SyncScheduler';
|
||||
|
||||
|
|
@ -288,6 +289,117 @@ function ThreatsTab() {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Cloud Users Tab ───────────────────────────────────────────────────────────
|
||||
function CloudUserTab() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [domain, setDomain] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
const handleEmailChange = (v: string) => {
|
||||
setEmail(v);
|
||||
const atIdx = v.indexOf('@');
|
||||
if (atIdx >= 0) setDomain(v.slice(atIdx + 1));
|
||||
};
|
||||
|
||||
const lookup = async () => {
|
||||
if (!email || !domain) return;
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setShowRaw(false);
|
||||
try {
|
||||
const params = new URLSearchParams({ emailAddress: email, domain });
|
||||
const res = await fetch(`/api/mimecast/cloud-user?${params}`);
|
||||
setResult(await res.json());
|
||||
} catch (err: any) {
|
||||
setResult({ error: err.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const user = result?.user;
|
||||
const lockedOut = user?.lockedOut ?? false;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">Email address</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="user@domain.com"
|
||||
value={email}
|
||||
onChange={e => handleEmailChange(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && lookup()}
|
||||
className="border rounded-md px-3 py-1.5 text-sm bg-background w-72"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">Domain</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="domain.com"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && lookup()}
|
||||
className="border rounded-md px-3 py-1.5 text-sm bg-background w-48"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={lookup} disabled={loading || !email || !domain}>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Search className="w-4 h-4 mr-1" />}
|
||||
Look Up
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result?.error && (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-950/20 p-3 text-sm text-red-600">
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && !result.error && !result.found && (
|
||||
<div className="rounded-lg border p-4 text-sm text-muted-foreground">
|
||||
User not found in Mimecast Cloud Gateway.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<div className="space-y-3">
|
||||
<div className={`rounded-lg border p-4 flex items-center gap-3 ${lockedOut ? 'border-red-400 bg-red-50 dark:bg-red-950/20' : 'border-green-400 bg-green-50 dark:bg-green-950/20'}`}>
|
||||
{lockedOut
|
||||
? <LockKeyhole className="w-5 h-5 text-red-600 shrink-0" />
|
||||
: <UnlockKeyhole className="w-5 h-5 text-green-600 shrink-0" />}
|
||||
<div>
|
||||
<p className={`font-semibold text-sm ${lockedOut ? 'text-red-700' : 'text-green-700'}`}>
|
||||
{lockedOut ? 'Account Locked Out' : 'Account Active'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{user.name && <span>{user.name} · </span>}
|
||||
{user.emailAddress}
|
||||
{user.status && <span> · Status: {user.status}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||
onClick={() => setShowRaw(v => !v)}
|
||||
>
|
||||
{showRaw ? 'Hide' : 'Show'} raw response
|
||||
</button>
|
||||
{showRaw && (
|
||||
<pre className="rounded-lg border bg-muted/30 p-3 text-xs overflow-auto max-h-72">
|
||||
{JSON.stringify(user._raw ?? user, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── History Tab ───────────────────────────────────────────────────────────────
|
||||
function HistoryTab() {
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
|
|
@ -346,6 +458,177 @@ function HistoryTab() {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Held Mail Tab ─────────────────────────────────────────────────────────────
|
||||
function HeldMailTab() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recipient, setRecipient] = useState('');
|
||||
const [tenantFilter, setTenantFilter] = useState('');
|
||||
const [policyFilter, setPolicyFilter] = useState('');
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const load = (recipientVal?: string) => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
const r = recipientVal ?? recipient;
|
||||
if (r) params.set('recipient', r);
|
||||
if (tenantFilter) params.set('tenantId', tenantFilter);
|
||||
fetch(`/api/mimecast/held?${params}`)
|
||||
.then(res => res.json())
|
||||
.then(d => { setData(d); setLoaded(true); })
|
||||
.catch(() => setData(null))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const messages: any[] = data?.messages ?? [];
|
||||
const filtered = policyFilter
|
||||
? messages.filter(m => m.policyInfo?.toLowerCase().includes(policyFilter.toLowerCase()))
|
||||
: messages;
|
||||
|
||||
const policies = [...new Set(messages.map((m: any) => m.policyInfo).filter(Boolean))].sort();
|
||||
const tenants: any[] = data?.tenants ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search bar */}
|
||||
<div className="flex flex-wrap gap-2 items-end">
|
||||
<div className="flex-1 min-w-56">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Recipient email</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="filter by recipient address…"
|
||||
value={recipient}
|
||||
onChange={e => setRecipient(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && load()}
|
||||
className="w-full border rounded-md px-3 py-1.5 text-sm bg-background"
|
||||
/>
|
||||
</div>
|
||||
{tenants.length > 1 && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Tenant</label>
|
||||
<select value={tenantFilter} onChange={e => setTenantFilter(e.target.value)}
|
||||
className="border rounded-md px-3 py-1.5 text-sm bg-background">
|
||||
<option value="">All tenants</option>
|
||||
{tenants.map(t => (
|
||||
<option key={t.tenantId} value={String(t.tenantId)}>{t.accountName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{loaded && policies.length > 0 && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Policy</label>
|
||||
<select value={policyFilter} onChange={e => setPolicyFilter(e.target.value)}
|
||||
className="border rounded-md px-3 py-1.5 text-sm bg-background min-w-48">
|
||||
<option value="">All policies</option>
|
||||
{policies.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={() => load()} disabled={loading} className="gap-2">
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
{loaded ? 'Refresh' : 'Load Held Mail'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tenant summary badges */}
|
||||
{loaded && tenants.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tenants.map(t => (
|
||||
<div key={t.tenantId} className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs border ${
|
||||
t.error ? 'border-red-300 bg-red-50 dark:bg-red-950/20 text-red-600' :
|
||||
t.count > 0 ? 'border-yellow-400/40 bg-yellow-500/5 text-yellow-700' :
|
||||
'border-border bg-muted/30 text-muted-foreground'
|
||||
}`}>
|
||||
<Building2 className="w-3 h-3" />
|
||||
<span className="font-medium">{t.accountName}</span>
|
||||
{t.error
|
||||
? <span>error</span>
|
||||
: <span>— {t.count.toLocaleString()}{t.totalCount > t.count ? ` of ${t.totalCount.toLocaleString()}` : ''} held</span>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loaded && !loading && (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground text-sm">
|
||||
Click “Load Held Mail” to fetch held messages across all configured Mimecast tenants.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Fetching held messages from all tenants…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loaded && !loading && filtered.length === 0 && (
|
||||
<div className="rounded-lg border p-12 text-center text-muted-foreground text-sm">
|
||||
No held messages found.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loaded && !loading && filtered.length > 0 && (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<div className="px-4 py-2 bg-muted/40 border-b flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{filtered.length.toLocaleString()} held message{filtered.length !== 1 ? 's' : ''}
|
||||
{data?.totalCount > filtered.length ? ` (showing ${filtered.length} of ${data.totalCount.toLocaleString()} total)` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/30">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium">Date</th>
|
||||
<th className="text-left px-4 py-2 font-medium">To</th>
|
||||
<th className="text-left px-4 py-2 font-medium">From</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Subject</th>
|
||||
<th className="text-left px-4 py-2 font-medium">Policy</th>
|
||||
{tenants.length > 1 && <th className="text-left px-4 py-2 font-medium">Tenant</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((m: any) => (
|
||||
<tr key={m.id} className="hover:bg-muted/20">
|
||||
<td className="px-4 py-2 text-muted-foreground whitespace-nowrap text-xs">
|
||||
{new Date(m.dateReceived).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="text-xs">{m.to}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="font-medium text-xs">{m.fromDisplay || m.from}</div>
|
||||
{m.fromDisplay && <div className="text-xs text-muted-foreground">{m.from}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-2 max-w-xs">
|
||||
<div className="truncate">{m.subject || '(no subject)'}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
m.policyInfo?.includes('DMARC') || m.policyInfo?.includes('Impersonation')
|
||||
? 'bg-red-500/10 text-red-600'
|
||||
: 'bg-yellow-500/10 text-yellow-700'
|
||||
}`}>
|
||||
{m.policyInfo || m.reason || '—'}
|
||||
</span>
|
||||
</td>
|
||||
{tenants.length > 1 && (
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{m.accountName}</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
export default function MimecastSyncPage() {
|
||||
const [statusData, setStatusData] = useState<any>(null);
|
||||
|
|
@ -414,21 +697,25 @@ export default function MimecastSyncPage() {
|
|||
)}
|
||||
|
||||
<Tabs defaultValue="status" className="w-full">
|
||||
<TabsList className="grid w-full max-w-2xl grid-cols-5">
|
||||
<TabsTrigger value="status" className="gap-1.5"><Activity className="h-4 w-4" />Status</TabsTrigger>
|
||||
<TabsTrigger value="messages" className="gap-1.5"><Mail className="h-4 w-4" />Messages</TabsTrigger>
|
||||
<TabsTrigger value="threats" className="gap-1.5"><Shield className="h-4 w-4" />Threats</TabsTrigger>
|
||||
<TabsTrigger value="history" className="gap-1.5"><Clock className="h-4 w-4" />History</TabsTrigger>
|
||||
<TabsTrigger value="schedules" className="gap-1.5"><Calendar className="h-4 w-4" />Schedules</TabsTrigger>
|
||||
<TabsList className="grid w-full max-w-4xl grid-cols-7">
|
||||
<TabsTrigger value="status" className="gap-1.5"><Activity className="h-4 w-4" />Status</TabsTrigger>
|
||||
<TabsTrigger value="held" className="gap-1.5"><PauseCircle className="h-4 w-4" />Held Mail</TabsTrigger>
|
||||
<TabsTrigger value="messages" className="gap-1.5"><Mail className="h-4 w-4" />Messages</TabsTrigger>
|
||||
<TabsTrigger value="threats" className="gap-1.5"><Shield className="h-4 w-4" />Threats</TabsTrigger>
|
||||
<TabsTrigger value="cloudusers" className="gap-1.5"><Users className="h-4 w-4" />Cloud Users</TabsTrigger>
|
||||
<TabsTrigger value="history" className="gap-1.5"><Clock className="h-4 w-4" />History</TabsTrigger>
|
||||
<TabsTrigger value="schedules" className="gap-1.5"><Calendar className="h-4 w-4" />Schedules</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="status" className="mt-6">
|
||||
<TabsContent value="status" className="mt-6">
|
||||
<StatusTab data={statusData} onSync={handleSync} syncing={syncing} />
|
||||
</TabsContent>
|
||||
<TabsContent value="messages" className="mt-6"><MessagesTab /></TabsContent>
|
||||
<TabsContent value="threats" className="mt-6"><ThreatsTab /></TabsContent>
|
||||
<TabsContent value="history" className="mt-6"><HistoryTab /></TabsContent>
|
||||
<TabsContent value="schedules" className="mt-6"><SyncScheduler /></TabsContent>
|
||||
<TabsContent value="held" className="mt-6"><HeldMailTab /></TabsContent>
|
||||
<TabsContent value="messages" className="mt-6"><MessagesTab /></TabsContent>
|
||||
<TabsContent value="threats" className="mt-6"><ThreatsTab /></TabsContent>
|
||||
<TabsContent value="cloudusers" className="mt-6"><CloudUserTab /></TabsContent>
|
||||
<TabsContent value="history" className="mt-6"><HistoryTab /></TabsContent>
|
||||
<TabsContent value="schedules" className="mt-6"><SyncScheduler /></TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
22
app/api/mimecast/cloud-user/route.ts
Normal file
22
app/api/mimecast/cloud-user/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getMimecastClient } from '@/lib/services/mimecast-client';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const emailAddress = req.nextUrl.searchParams.get('emailAddress');
|
||||
const domain = req.nextUrl.searchParams.get('domain');
|
||||
|
||||
if (!emailAddress || !domain) {
|
||||
return NextResponse.json({ error: 'emailAddress and domain are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const client = getMimecastClient();
|
||||
const user = await client.getCloudUser(emailAddress, domain);
|
||||
if (!user) {
|
||||
return NextResponse.json({ found: false });
|
||||
}
|
||||
return NextResponse.json({ found: true, user });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
91
app/api/mimecast/held/route.ts
Normal file
91
app/api/mimecast/held/route.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { getMimecastClientForTenant } from '@/lib/services/mimecast-client';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const tenantId = searchParams.get('tenantId');
|
||||
const recipient = searchParams.get('recipient') ?? undefined;
|
||||
|
||||
// Build query — filter by tenant if provided, else all enabled tenants
|
||||
let tenants: any[];
|
||||
if (tenantId) {
|
||||
const r = await postgresClient.query(
|
||||
`SELECT mt.*, c.company_name FROM mimecast_tenants mt
|
||||
LEFT JOIN companies c ON c.id = mt.company_id
|
||||
WHERE mt.id = $1 AND mt.enabled = true`,
|
||||
[tenantId]
|
||||
);
|
||||
tenants = r.rows;
|
||||
} else {
|
||||
const r = await postgresClient.query(
|
||||
`SELECT mt.*, c.company_name FROM mimecast_tenants mt
|
||||
LEFT JOIN companies c ON c.id = mt.company_id
|
||||
WHERE mt.enabled = true
|
||||
ORDER BY mt.account_name`
|
||||
);
|
||||
tenants = r.rows;
|
||||
}
|
||||
|
||||
if (!tenants.length) {
|
||||
return NextResponse.json({ error: 'No configured Mimecast tenants found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch held messages for each tenant in parallel
|
||||
const results = await Promise.allSettled(
|
||||
tenants.map(async (tenant) => {
|
||||
const client = getMimecastClientForTenant(tenant);
|
||||
const { messages, totalCount } = await client.getHeldMessages({ recipient, maxMessages: 500 });
|
||||
return {
|
||||
tenantId: tenant.id,
|
||||
accountCode: tenant.account_code,
|
||||
accountName: tenant.account_name,
|
||||
companyName: tenant.company_name ?? tenant.account_name,
|
||||
companyId: tenant.company_id,
|
||||
messages,
|
||||
totalCount,
|
||||
error: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const tenantResults = results.map((r, i) => {
|
||||
if (r.status === 'fulfilled') return r.value;
|
||||
return {
|
||||
tenantId: tenants[i].id,
|
||||
accountCode: tenants[i].account_code,
|
||||
accountName: tenants[i].account_name,
|
||||
companyName: tenants[i].company_name ?? tenants[i].account_name,
|
||||
companyId: tenants[i].company_id,
|
||||
messages: [],
|
||||
totalCount: 0,
|
||||
error: r.reason?.message ?? 'Failed to fetch',
|
||||
};
|
||||
});
|
||||
|
||||
const allMessages = tenantResults.flatMap(t =>
|
||||
t.messages.map((m: any) => ({ ...m, tenantId: t.tenantId, accountName: t.accountName, companyName: t.companyName }))
|
||||
);
|
||||
|
||||
// Sort all messages newest first
|
||||
allMessages.sort((a, b) => new Date(b.dateReceived).getTime() - new Date(a.dateReceived).getTime());
|
||||
|
||||
return NextResponse.json({
|
||||
tenants: tenantResults.map(t => ({
|
||||
tenantId: t.tenantId,
|
||||
accountName: t.accountName,
|
||||
companyName: t.companyName,
|
||||
companyId: t.companyId,
|
||||
count: t.messages.length,
|
||||
totalCount: t.totalCount,
|
||||
error: t.error,
|
||||
})),
|
||||
messages: allMessages,
|
||||
totalCount: allMessages.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Mimecast] held messages error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -6,18 +6,20 @@ export async function POST(request: NextRequest) {
|
|||
try {
|
||||
console.log('Starting classification icons sync...');
|
||||
|
||||
// Fetch classification icons from Autotask API
|
||||
// Fetch classification picklist from Companies field info
|
||||
const autotaskClient = getAutotaskClient();
|
||||
const classifications = await autotaskClient.getClassificationIcons();
|
||||
|
||||
if (!classifications || classifications.length === 0) {
|
||||
const fields = await autotaskClient.getFieldInfo('Companies');
|
||||
const classificationField = fields.find((f: any) => f.name === 'classification');
|
||||
|
||||
if (!classificationField || !classificationField.picklistValues?.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No classifications found in Autotask' },
|
||||
{ error: 'No classification picklist found in Companies field info' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Fetched ${classifications.length} classification icons from Autotask`);
|
||||
const classifications = classificationField.picklistValues;
|
||||
console.log(`Fetched ${classifications.length} classification values from Autotask`);
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
|
|
@ -26,16 +28,16 @@ export async function POST(request: NextRequest) {
|
|||
for (const classification of classifications) {
|
||||
const result = await postgresClient.query(
|
||||
`INSERT INTO company_classifications (
|
||||
classification_id,
|
||||
name,
|
||||
description,
|
||||
classification_id,
|
||||
name,
|
||||
description,
|
||||
is_active,
|
||||
is_system,
|
||||
updated_at,
|
||||
synced_at
|
||||
) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (classification_id)
|
||||
DO UPDATE SET
|
||||
ON CONFLICT (classification_id)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
is_active = EXCLUDED.is_active,
|
||||
|
|
@ -44,9 +46,9 @@ export async function POST(request: NextRequest) {
|
|||
synced_at = CURRENT_TIMESTAMP
|
||||
RETURNING (xmax = 0) AS inserted`,
|
||||
[
|
||||
classification.id,
|
||||
classification.name,
|
||||
classification.description || null,
|
||||
parseInt(String(classification.value)),
|
||||
classification.label,
|
||||
null,
|
||||
classification.isActive !== false,
|
||||
classification.isSystem || false
|
||||
]
|
||||
|
|
@ -66,7 +68,7 @@ export async function POST(request: NextRequest) {
|
|||
total: classifications.length,
|
||||
inserted,
|
||||
updated,
|
||||
message: `Synced ${classifications.length} classification icons`
|
||||
message: `Synced ${classifications.length} classifications from Companies field info`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
|
|
|||
280
docs/DATTO_RMM_API_GUIDE.md
Normal file
280
docs/DATTO_RMM_API_GUIDE.md
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
# Datto RMM API Guide for openclaw
|
||||
|
||||
Everything learned from building the Datto RMM integration in Pulse. Use this as a reference for any future work touching the Datto RMM API.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
Datto RMM API v2 uses **OAuth2 with password grant type** — not a simple API key header.
|
||||
|
||||
- **Auth endpoint**: `https://concord-api.centrastage.net/auth/oauth/token`
|
||||
- **Base API URL**: `https://concord-api.centrastage.net/api/v2`
|
||||
- **Client credentials**: always `public-client:public` (this is the public OAuth client — not your credentials)
|
||||
- **Your credentials**: API Access Key = `username`, API Secret Key = `password`
|
||||
|
||||
```http
|
||||
POST https://concord-api.centrastage.net/auth/oauth/token
|
||||
Authorization: Basic cHVibGljLWNsaWVudDpwdWJsaWM= (base64 of "public-client:public")
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=password&username=YOUR_API_KEY&password=YOUR_API_SECRET
|
||||
```
|
||||
|
||||
Response returns `access_token` (bearer token). Tokens last ~1 hour; refresh proactively at 50 minutes.
|
||||
|
||||
**Environment variables used in Pulse:**
|
||||
```
|
||||
DATTO_RMM_API_URL=https://concord-api.centrastage.net
|
||||
DATTO_RMM_API_KEY=<your API access key>
|
||||
DATTO_RMM_API_SECRET=<your API secret key>
|
||||
DATTO_RMM_WEBHOOK_SECRET=<shared secret for webhook validation>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Making API Calls
|
||||
|
||||
All requests go to `https://concord-api.centrastage.net/api/v2{endpoint}` with:
|
||||
|
||||
```http
|
||||
Authorization: Bearer {access_token}
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
The response body is always JSON. Always parse it — the API never returns empty 200s (except for write operations where you should guard with `text ? JSON.parse(text) : {}`).
|
||||
|
||||
---
|
||||
|
||||
## Key Endpoints
|
||||
|
||||
### Sites
|
||||
```
|
||||
GET /account/sites → { sites: [...], pageDetails: {...} }
|
||||
GET /site/{siteUid}/devices → { devices: [...] }
|
||||
```
|
||||
|
||||
Sites map to Autotask companies via `autotaskCompanyId` / `autotaskCompanyName` fields on the site object. This is the primary join key between the two systems.
|
||||
|
||||
### Devices
|
||||
```
|
||||
GET /account/devices → { devices: [...], pageDetails: {...} }
|
||||
GET /devices/{deviceId} → { item: DattoRMMDevice }
|
||||
GET /device/{deviceId}/auditdata → detailed hardware info (bios, processors, memory, disks)
|
||||
```
|
||||
|
||||
Default page size is 250. Always paginate using `pageDetails.nextPageUrl` — follow it until null.
|
||||
|
||||
### Alerts
|
||||
```
|
||||
GET /account/alerts/open → { alerts: [...], pageDetails: {...} }
|
||||
GET /account/alerts/resolved → { alerts: [...], pageDetails: {...} }
|
||||
GET /alert/{alertUid} → single alert with full alertContext
|
||||
```
|
||||
|
||||
Resolved alerts can be huge — cap page fetches (e.g. 4 pages) rather than fetching all.
|
||||
|
||||
For PING alerts, `alertContext['@class'] === 'ping_ctx'` and `alertContext.instanceName` holds the ping target hostname.
|
||||
|
||||
### Components & Quick Jobs
|
||||
```
|
||||
GET /account/components → { components: [...] } (automation scripts/tasks)
|
||||
PUT /device/{deviceUid}/quickjob → run a component on a device
|
||||
GET /job/{jobUid}/results/{deviceUid} → job output/results
|
||||
```
|
||||
|
||||
Quick job payload:
|
||||
```json
|
||||
{
|
||||
"jobName": "My Job Name",
|
||||
"jobComponent": {
|
||||
"componentUid": "abc-123-...",
|
||||
"variables": [
|
||||
{ "name": "VAR_NAME", "value": "value" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pagination Pattern
|
||||
|
||||
The API uses `pageDetails.nextPageUrl` for cursor-based pagination. Always follow it:
|
||||
|
||||
```typescript
|
||||
let url: string | null = `https://concord-api.centrastage.net/api/v2/account/sites?pageSize=250`;
|
||||
while (url) {
|
||||
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const body = await resp.json();
|
||||
items.push(...(body.sites || []));
|
||||
url = body.pageDetails?.nextPageUrl ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
Some older endpoints use `?page=1&pageSize=250` query params — check which pattern each endpoint uses. The `fetchAllPages` helper in `datto-rmm-client.ts` handles the `nextPageUrl` style.
|
||||
|
||||
---
|
||||
|
||||
## Data Model (PostgreSQL Tables)
|
||||
|
||||
### `datto_rmm_sites`
|
||||
| Column | Notes |
|
||||
|--------|-------|
|
||||
| `id` | Datto integer ID (PK) |
|
||||
| `uid` | Datto GUID string (unique) |
|
||||
| `name` | Site display name |
|
||||
| `autotask_company_id` | FK → `companies(id)` — join key |
|
||||
| `autotask_company_name` | Denormalized for display |
|
||||
| `number_of_devices` | Online/offline counts from API |
|
||||
| `portal_url` | Deep link to Datto portal |
|
||||
|
||||
### `datto_rmm_devices`
|
||||
| Column | Notes |
|
||||
|--------|-------|
|
||||
| `id` | Datto integer ID (PK) |
|
||||
| `uid` | Datto GUID (unique, used for API calls and quick jobs) |
|
||||
| `site_id` | FK → `datto_rmm_sites(id)` |
|
||||
| `hostname` | Device hostname |
|
||||
| `online` / `suspended` / `deleted` | Status flags |
|
||||
| `device_type_category` | e.g. "Desktop", "Server", "Laptop" |
|
||||
| `operating_system` | OS string |
|
||||
| `antivirus_product` / `antivirus_status` | AV info |
|
||||
| `patch_status` | Patch management summary |
|
||||
| `patches_approved_pending` | Count of pending patches |
|
||||
| `last_seen` | Timestamp (ms epoch from API, stored as TIMESTAMPTZ) |
|
||||
| `web_remote_url` | Direct remote session URL |
|
||||
| `udf` | JSONB — User Defined Fields 1–10 from API |
|
||||
|
||||
### `datto_rmm_alerts`
|
||||
| Column | Notes |
|
||||
|--------|-------|
|
||||
| `alert_uid` | PK (Datto GUID string) |
|
||||
| `device_uid` / `device_name` | Source device |
|
||||
| `site_uid` / `site_name` | Source site |
|
||||
| `priority` | "Critical", "High", "Moderate", "Low", "Information" |
|
||||
| `alert_context` | JSONB — varies by alert type, contains `@class` discriminator |
|
||||
| `resolved` | Boolean |
|
||||
| `resolved_on` | Timestamp |
|
||||
| `muted` | Boolean |
|
||||
| `ticket_number` | Linked Autotask ticket if any |
|
||||
| `alert_category` | e.g. "Patch Management" |
|
||||
| `alert_type` | e.g. "PING", "DISK", "CPU" |
|
||||
| `alert_message_en` | Human-readable alert message |
|
||||
| `device_udf1`–`device_udf29` | All 29 UDF fields from webhook payloads |
|
||||
| `ping_target` | Resolved from API for PING alerts |
|
||||
| `triggered` | Raw webhook "True"/"False" string |
|
||||
|
||||
### `datto_rmm_webhook_logs`
|
||||
Raw capture table — stores every incoming webhook payload verbatim for inspection before processing.
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
Datto RMM can POST alert events to your endpoint when alerts fire or resolve.
|
||||
|
||||
**Webhook receiver**: `POST /api/webhooks/datto-rmm`
|
||||
|
||||
**Authentication**: Datto sends a shared secret in the `X-Datto-Webhook-Secret` header. Validate it against `DATTO_RMM_WEBHOOK_SECRET` env var.
|
||||
|
||||
**Alert webhook payload shape** (flat JSON, not nested like the REST API):
|
||||
```json
|
||||
{
|
||||
"alert_uid": "abc-123-...",
|
||||
"triggered": "True", // "True" = alert fired, "False" = resolved
|
||||
"alert_type": "PING",
|
||||
"alert_category": "Networking",
|
||||
"alert_priority": "Critical",
|
||||
"alert_message_en": "Ping monitor failed for ...",
|
||||
"device_uid": "...",
|
||||
"device_hostname": "SERVER01",
|
||||
"device_ip": "10.0.0.1",
|
||||
"device_os": "Windows Server 2019",
|
||||
"device_description": "...",
|
||||
"device_id": "12345",
|
||||
"site_uid": "...",
|
||||
"site_name": "ACME Corp",
|
||||
"site_id": "678",
|
||||
"platform": "Windows",
|
||||
"last_user": "DOMAIN\\user",
|
||||
"device_udf1": "...",
|
||||
// ... device_udf2 through device_udf29
|
||||
}
|
||||
```
|
||||
|
||||
Key gotcha: `triggered === "True"` means the alert is **active** (not resolved). `triggered === "False"` means it **resolved**. Map `triggered === "False"` → `resolved = true`.
|
||||
|
||||
**Always return HTTP 200** even on errors — Datto will disable your webhook endpoint if it receives repeated non-200 responses.
|
||||
|
||||
---
|
||||
|
||||
## Sync Architecture in Pulse
|
||||
|
||||
The sync pipeline runs in order: **sites → devices → open_alerts → resolved_alerts**
|
||||
|
||||
Sites must sync before devices (FK constraint). The sync service guards against orphaned FK refs by pre-fetching known IDs and setting FK fields to null when the parent doesn't exist yet.
|
||||
|
||||
Timestamps from the Datto API come as **millisecond epoch integers**. Convert with `new Date(milliseconds)` before storing in PostgreSQL.
|
||||
|
||||
The factory singleton (`datto-rmm-factory.ts`) is the standard way to get a client instance in API routes:
|
||||
```typescript
|
||||
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
|
||||
const client = getDattoRMMClient();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
1. **Auth URL is different from API URL** — token is fetched from `concord-api.centrastage.net/auth/...`, API calls go to `concord-api.centrastage.net/api/v2/...`
|
||||
|
||||
2. **Parse auth response carefully** — if auth fails, the server may return HTML (an error page) instead of JSON. Always try/catch the JSON.parse and log the raw text on failure.
|
||||
|
||||
3. **`uid` vs `id`** — devices and sites have both an integer `id` and a GUID `uid`. The REST API uses `uid` in paths for most operations. Quick jobs require `deviceUid` (the GUID), not the integer ID.
|
||||
|
||||
4. **UDFs in webhooks vs REST API** — The REST API returns UDFs as `udf: { udf1: "...", udf2: "..." }` (nested object, up to 10). Webhooks flatten them to `device_udf1` through `device_udf29` as top-level fields (29 total).
|
||||
|
||||
5. **Alert context varies by type** — always check `alertContext['@class']` to know what fields are available. For PING alerts, fetch `/alert/{uid}` to get `instanceName` (the ping target) since it's not in the bulk alert list response.
|
||||
|
||||
6. **Page size 250** is the effective maximum — don't request more.
|
||||
|
||||
7. **Resolved alerts grow without bound** — never fetch all resolved alerts in production. Limit to recent pages (e.g. 4 pages = ~1000 most recent).
|
||||
|
||||
8. **Site → Company mapping** — `site.autotaskCompanyId` is a string from the API even though it's an integer ID. Always `parseInt()` it and validate `> 0` and `!isNaN()` before using as a FK.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Alert Priority Values
|
||||
|
||||
- `Critical`
|
||||
- `High`
|
||||
- `Moderate`
|
||||
- `Low`
|
||||
- `Information`
|
||||
|
||||
## Quick Reference: Common Alert Types
|
||||
|
||||
- `PING` — ping monitor failure
|
||||
- `DISK` — disk space/health
|
||||
- `CPU` — CPU utilization
|
||||
- `MEMORY` — RAM utilization
|
||||
- `SERVICE` — Windows service down
|
||||
- `EVENTLOG` — Windows event log match
|
||||
- `PATCH` — patch management
|
||||
- `ANTIVIRUS` — AV status
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Integration
|
||||
|
||||
In Pulse, incoming Datto RMM webhooks can trigger the **pipeline engine** (fire-and-forget):
|
||||
|
||||
```typescript
|
||||
pipelineEngine.processTrigger('datto_rmm', payload).catch(err =>
|
||||
console.error('[DATTO-RMM-WEBHOOK] Pipeline processing error:', err)
|
||||
);
|
||||
```
|
||||
|
||||
The trigger type `'datto_rmm'` matches pipeline rules configured in the admin UI. Pipelines can take actions like creating Autotask tickets, sending notifications, etc.
|
||||
104
docs/clista-changes-2026-03-26.md
Normal file
104
docs/clista-changes-2026-03-26.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Clista Electric — User List Changes Since January 2026
|
||||
|
||||
_Comparison of "Clista Approved - User List and Classification.xlsx" against Autotask/M365 as of 2026-03-26. Enriched with ticket history._
|
||||
|
||||
---
|
||||
|
||||
## New Users Added Since January
|
||||
|
||||
| Name | Email | Added | Classification (from tickets) |
|
||||
|---|---|---|---|
|
||||
| **Brett Tokarski** | btokarski@clistaelectric.com | Jan 6 | Likely **BRONZE** — iPad + Bluebeam Complete (estimator/PM profile), no laptop deployed |
|
||||
| **Joe Glassbrenner** | jglassbrenner@clistaelectric.com | Jan 6 | Likely **EMAIL ONLY** — iPad setup only, no workstation |
|
||||
| **Daniel Archer** | darcher@clistaelectric.com | Mar 3 | Likely **EMAIL ONLY** — mailbox + Foreman distribution list + iPad; field foreman |
|
||||
|
||||
New device added: **SPRO009** (Jan 15, assigned to Josh Miller — "Activate SPRO009 and install Wulf tools")
|
||||
|
||||
---
|
||||
|
||||
## Deleted Since January
|
||||
|
||||
- **Joe Laplace** (jlaplace@clistaelectric.com) — removed Jan 6. His laptop (**LT049**) was reassigned to Bryan Detweiler (ticket T20260119.0181).
|
||||
|
||||
---
|
||||
|
||||
## Users in Autotask NOT on the Spreadsheet
|
||||
|
||||
Ticket history used to determine likely classification:
|
||||
|
||||
| Name | Email | Likely Classification | Evidence |
|
||||
|---|---|---|---|
|
||||
| **Bryan Detweiler** | bdetweiler@clistaelectric.com | **BRONZE USER** | Received LT049 (Joe Laplace's repurposed laptop, Jan 13). iPad email setup Oct 2025. |
|
||||
| **Nicholas Flinko** | nflinko@clistaelectric.com | **BRONZE USER** | Has LT032 (multiple patch/disk/Teams tickets on LT032 under his name). Spreadsheet incorrectly lists LT032 under Jared Boger. |
|
||||
| **Tanner Lacher** | tlacher@clistaelectric.com | **EMAIL ONLY** | "New iPad and Email Setup Request" Oct 2025 — iPad only, no workstation. |
|
||||
| **Randy Nocleg** | rnocleg@clistaelectric.com | **EMAIL ONLY** | "Mobile email set up" Jun 2025 — mobile only. |
|
||||
| **John Pronko** | jpronko@clistaelectric.com | **EMAIL ONLY** | "Duo and Outlook Setup on iPad" Nov 2025 — iPad only. |
|
||||
| **Bill Shindledecker** | bShindledecker@clistaelectric.com | **EMAIL ONLY** | "Email Setup for iPad" Oct 2025 — iPad only. |
|
||||
| **Jeff Gatto** | jgatto@clistaelectric.com | **Unknown** | No tickets found. |
|
||||
| **Rob Gerhart** | rgerhart@clistaelectric.com | **Unknown** | No tickets found. |
|
||||
| **Mike Jr.** | mikejr@clistaelectric.com | **Likely duplicate** | Only 2 tickets (unusual sign-in alerts, Oct 2025). Likely a legacy/stale account for Mike Clista Jr. (mclistajr@). |
|
||||
| **safety unknown** | safety@clistaelectric.com | **DEVICE EMAIL or GENERAL** | Generic safety mailbox — no user-specific tickets. |
|
||||
|
||||
---
|
||||
|
||||
## Flagged REMOVE — Still Have Active Licenses in M365
|
||||
|
||||
Both are still licensed in the M365 export:
|
||||
|
||||
- **CAD1** — still has Microsoft 365 Business Standard
|
||||
- **Sam Cocola** — still has Microsoft 365 Basic
|
||||
|
||||
---
|
||||
|
||||
## Device Reassignments Found in Tickets
|
||||
|
||||
Devices that moved or were reassigned since the spreadsheet was made:
|
||||
|
||||
| Device | Old Assignment (spreadsheet) | New Assignment (from tickets) | Notes |
|
||||
|---|---|---|---|
|
||||
| **LT049** | (not on spreadsheet) | Bryan Detweiler | Reassigned from Joe Laplace when he left (Jan 13) |
|
||||
| **LT074** | (not on spreadsheet) | Mark Blum | Repurposed deployment Mar 4; onboarding check-in Mar 11. **Mark Blum should be reclassified — he was EMAIL ONLY but now has a workstation.** |
|
||||
| **LT078** | (not on spreadsheet) | Brendon Bittel | Patch alerts Dec 2025. **Bittel was EMAIL ONLY but now has a laptop.** |
|
||||
| **LT079** | (not on spreadsheet) | Travis Lenhart | Patch failure Mar 2026. Lenhart was EMAIL ONLY with MIX003 (now inactive). **Upgrade to BRONZE.** |
|
||||
| **LT080** | (not on spreadsheet) | Andrew Holzworth | Docking station ticket Feb 2026. LT040 (his old device) was repurposed for field use Jan 20. |
|
||||
| **LT076** | (not on spreadsheet) | Roseann March | VPN confirmation ticket Nov 2025. She already had WL-LT004 + DT034; LT076 may be a replacement. |
|
||||
| **LT077** | (not on spreadsheet) | Roseann March | Patch failure Dec 2025 — second device or replacement for WL-LT004. |
|
||||
| **LT032** | Jared Boger (EMAIL ONLY) | Nicholas Flinko | Flinko is the contact on all LT032 alerts — Boger classification may be stale. |
|
||||
|
||||
---
|
||||
|
||||
## Users Whose Classification Should Be Updated
|
||||
|
||||
Based on device deployments found in tickets:
|
||||
|
||||
| Name | Current Classification | Suggested Update | Reason |
|
||||
|---|---|---|---|
|
||||
| **Mark Blum** | EMAIL ONLY | **BRONZE USER** | Received workstation LT074 (Mar 2026) |
|
||||
| **Brendon Bittel** | EMAIL ONLY | **BRONZE USER** | Has laptop LT078 (added Dec 2025) |
|
||||
| **Travis Lenhart** | EMAIL ONLY | **BRONZE USER** | Has laptop LT079; MIX003 retired |
|
||||
| **Bryan Detweiler** | Not on list | **BRONZE USER** | Add to list — has LT049 |
|
||||
| **Jared Boger** | EMAIL ONLY (LT032) | Review — LT032 now under Flinko | Boger may have no device now |
|
||||
|
||||
---
|
||||
|
||||
## Devices on Spreadsheet Now Inactive in DB
|
||||
|
||||
| Device | Assigned To (per spreadsheet) | Status | Likely Replacement |
|
||||
|---|---|---|---|
|
||||
| LT017 | Justin Klosky | Inactive | Unknown |
|
||||
| LT028 | Anthony Laskey | Inactive | Unknown |
|
||||
| LT032 | Jared Boger | Inactive | Now under Nicholas Flinko |
|
||||
| MIX003 | Travis Lenhart | Inactive | LT079 |
|
||||
| WL-LT004 | Roseann March | Inactive | LT076 or LT077 |
|
||||
| LT040 | Andrew Holzworth | Repurposed | LT080 — "repurposed for field use" Jan 20 |
|
||||
|
||||
---
|
||||
|
||||
## Data Quality Issues
|
||||
|
||||
- **Dominic Edwards** — email typo in Autotask: `dedwards@clistaelectric.om` (missing the 'c')
|
||||
- **Ron Marangoni** — spreadsheet says `marangoni@`, M365 and Autotask both show `rmarangoni@`
|
||||
- **Sonny Stewart** — spreadsheet lists "Microsoft 365" license but M365 export shows no license assigned
|
||||
- **Michael Skibinski** — duplicate contact record created Feb 13, 2026 (two records, same email)
|
||||
- **Dave Warywoda**, **Grant Hoffman**, **Dominic Edwards**, **Donald Maraugha** — on rachel_list but absent from M365 export entirely
|
||||
- **LT073 and LT075** — active devices with no ticket history found; assignment unknown
|
||||
|
|
@ -73,6 +73,32 @@ export interface MimecastMessageInfo {
|
|||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MimecastHeldMessage {
|
||||
id: string;
|
||||
subject: string;
|
||||
from: string;
|
||||
fromDisplay: string;
|
||||
to: string;
|
||||
toDisplay: string;
|
||||
dateReceived: string;
|
||||
reason: string;
|
||||
reasonCode: string;
|
||||
policyInfo: string;
|
||||
route: string;
|
||||
hasAttachments: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface MimecastCloudUser {
|
||||
emailAddress: string;
|
||||
domain: string;
|
||||
lockedOut: boolean;
|
||||
status?: string;
|
||||
name?: string;
|
||||
alias?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
nextCursor: string | null;
|
||||
|
|
@ -398,6 +424,87 @@ export class MimecastClient {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Cloud Gateway ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /user/cloud-gateway/v1/users?emailAddress=...&domain=...
|
||||
*/
|
||||
async getCloudUser(emailAddress: string, domain: string): Promise<MimecastCloudUser | null> {
|
||||
const data = await this.request<any>('GET', '/user/cloud-gateway/v1/users', undefined, {
|
||||
emailAddress,
|
||||
domain,
|
||||
});
|
||||
const user = data?.value?.[0] ?? data?.data?.[0] ?? null;
|
||||
if (!user) return null;
|
||||
return {
|
||||
emailAddress: user.emailAddress ?? emailAddress,
|
||||
domain: user.domain ?? domain,
|
||||
lockedOut: user.lockedOut ?? false,
|
||||
status: user.status ?? undefined,
|
||||
name: user.name ?? undefined,
|
||||
alias: user.alias ?? undefined,
|
||||
_raw: user,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Held Messages ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/gateway/get-hold-message-list
|
||||
* admin: true = see user-level hold queues as an admin
|
||||
* pageSize is ignored by the API (always returns 10); paginate via meta.pagination.next
|
||||
* Fetches ALL pages up to maxMessages limit.
|
||||
*/
|
||||
async getHeldMessages(options: {
|
||||
recipient?: string;
|
||||
maxMessages?: number;
|
||||
} = {}): Promise<{ messages: MimecastHeldMessage[]; totalCount: number }> {
|
||||
const maxMessages = options.maxMessages ?? 500;
|
||||
const all: MimecastHeldMessage[] = [];
|
||||
let cursor: string | null = null;
|
||||
let totalCount = 0;
|
||||
|
||||
do {
|
||||
const reqBody: any = { admin: true };
|
||||
if (options.recipient) {
|
||||
reqBody.searchBy = { fieldName: 'recipient', value: options.recipient };
|
||||
}
|
||||
|
||||
const body: any = { data: [reqBody] };
|
||||
if (cursor) {
|
||||
body.meta = { pagination: { pageToken: cursor } };
|
||||
}
|
||||
|
||||
const result = await this.request<any>('POST', '/api/gateway/get-hold-message-list', body);
|
||||
const pagination = result?.meta?.pagination ?? {};
|
||||
const msgs: any[] = result?.data ?? [];
|
||||
|
||||
if (totalCount === 0) totalCount = pagination.totalCount ?? msgs.length;
|
||||
|
||||
for (const m of msgs) {
|
||||
all.push({
|
||||
id: m.id,
|
||||
subject: m.subject ?? '',
|
||||
from: m.fromHeader?.emailAddress ?? m.from?.emailAddress ?? '',
|
||||
fromDisplay: m.fromHeader?.displayableName ?? m.from?.displayableName ?? '',
|
||||
to: m.to?.emailAddress ?? '',
|
||||
toDisplay: m.to?.displayableName ?? '',
|
||||
dateReceived: m.dateReceived ?? '',
|
||||
reason: m.reason ?? '',
|
||||
reasonCode: m.reasonCode ?? '',
|
||||
policyInfo: m.policyInfo ?? '',
|
||||
route: m.route ?? '',
|
||||
hasAttachments: m.hasAttachments ?? false,
|
||||
size: m.size ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
cursor = pagination.next ?? null;
|
||||
} while (cursor && all.length < maxMessages);
|
||||
|
||||
return { messages: all, totalCount };
|
||||
}
|
||||
|
||||
// ── Account ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -436,3 +543,17 @@ export function getMimecastClient(): MimecastClient {
|
|||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
export function getMimecastClientForTenant(tenant: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
base_url?: string;
|
||||
account_code?: string;
|
||||
}): MimecastClient {
|
||||
return new MimecastClient({
|
||||
clientId: tenant.client_id,
|
||||
clientSecret: tenant.client_secret,
|
||||
baseUrl: tenant.base_url ?? 'https://api.services.mimecast.com',
|
||||
accountCode: tenant.account_code ?? '',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
49
migrations/061_create_recurring_revenue_views.sql
Normal file
49
migrations/061_create_recurring_revenue_views.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
-- Views filtering all core Autotask data to CompanyCategory ID 1 (Recurring Revenue Customer)
|
||||
|
||||
-- Base: recurring revenue companies
|
||||
CREATE OR REPLACE VIEW v_mrr_companies AS
|
||||
SELECT *
|
||||
FROM companies
|
||||
WHERE company_category_id = 1
|
||||
AND is_active = true;
|
||||
|
||||
-- Tickets for recurring revenue companies
|
||||
CREATE OR REPLACE VIEW v_mrr_tickets AS
|
||||
SELECT t.*
|
||||
FROM tickets t
|
||||
INNER JOIN v_mrr_companies c ON c.id = t.company_id
|
||||
WHERE t.is_deleted = false;
|
||||
|
||||
-- Contacts for recurring revenue companies
|
||||
CREATE OR REPLACE VIEW v_mrr_contacts AS
|
||||
SELECT ct.*
|
||||
FROM contacts ct
|
||||
INNER JOIN v_mrr_companies c ON c.id = ct.company_id
|
||||
WHERE ct.is_deleted = false;
|
||||
|
||||
-- Contracts for recurring revenue companies
|
||||
CREATE OR REPLACE VIEW v_mrr_contracts AS
|
||||
SELECT cn.*
|
||||
FROM contracts cn
|
||||
INNER JOIN v_mrr_companies c ON c.id = cn.company_id
|
||||
WHERE cn.is_deleted = false;
|
||||
|
||||
-- Configuration items for recurring revenue companies
|
||||
CREATE OR REPLACE VIEW v_mrr_configuration_items AS
|
||||
SELECT ci.*
|
||||
FROM configuration_items ci
|
||||
INNER JOIN v_mrr_companies c ON c.id = ci.company_id
|
||||
WHERE ci.is_deleted = false;
|
||||
|
||||
-- Time entries for recurring revenue companies (via ticket)
|
||||
CREATE OR REPLACE VIEW v_mrr_time_entries AS
|
||||
SELECT te.*
|
||||
FROM time_entries te
|
||||
INNER JOIN v_mrr_tickets t ON t.id = te.ticket_id;
|
||||
|
||||
-- Billing items for recurring revenue companies
|
||||
CREATE OR REPLACE VIEW v_mrr_billing_items AS
|
||||
SELECT bi.*
|
||||
FROM billing_items bi
|
||||
INNER JOIN v_mrr_companies c ON c.id = bi.company_id
|
||||
WHERE bi.is_deleted = false;
|
||||
19
migrations/062_create_mimecast_tenants.sql
Normal file
19
migrations/062_create_mimecast_tenants.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- Migration 062: Mimecast multi-tenant credentials
|
||||
-- Stores per-client Mimecast API credentials for held mail and other per-tenant queries
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mimecast_tenants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL,
|
||||
account_code VARCHAR(50),
|
||||
account_name VARCHAR(255),
|
||||
client_id VARCHAR(255) NOT NULL,
|
||||
client_secret VARCHAR(255) NOT NULL,
|
||||
base_url VARCHAR(255) DEFAULT 'https://api.services.mimecast.com',
|
||||
enabled BOOLEAN DEFAULT true,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mimecast_tenants_company ON mimecast_tenants(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_mimecast_tenants_enabled ON mimecast_tenants(enabled);
|
||||
131
scripts/test-duo.mjs
Normal file
131
scripts/test-duo.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Quick connectivity test for both Duo API integrations.
|
||||
* Calls a lightweight read endpoint on each:
|
||||
* Accounts API → GET /admin/v1/accounts (lists child accounts)
|
||||
* Admin API → GET /admin/v1/info/summary (tenant summary stats)
|
||||
*
|
||||
* Duo auth: HMAC-SHA1 signed "Authorization: Basic <b64(ikey:sig)>" header
|
||||
* https://duo.com/docs/adminapi#authentication
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
|
||||
// ── credentials from .env.local ──────────────────────────────────────────────
|
||||
const ACCOUNTS = {
|
||||
ikey: process.env.DUOACCOUNTS_INTEGRATION_KEY,
|
||||
skey: process.env.DUOACCOUNTS_SECRET_KEY,
|
||||
host: process.env.DUOACCOUNTS_API_HOSTNAME,
|
||||
};
|
||||
const ADMIN = {
|
||||
ikey: process.env.DUOADMIN_INTEGRATION_KEY,
|
||||
skey: process.env.DUOADMIN_SECRET_KEY,
|
||||
host: process.env.DUOADMIN_API_HOSTNAME,
|
||||
};
|
||||
|
||||
// ── Duo HMAC signer ───────────────────────────────────────────────────────────
|
||||
function duoSign(ikey, skey, host, method, path, params = {}) {
|
||||
const date = new Date().toUTCString();
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
|
||||
.join('&');
|
||||
|
||||
const canon = [date, method.toUpperCase(), host.toLowerCase(), path, sortedParams].join('\n');
|
||||
const sig = crypto.createHmac('sha1', skey).update(canon).digest('hex');
|
||||
const auth = Buffer.from(`${ikey}:${sig}`).toString('base64');
|
||||
|
||||
return { date, auth: `Basic ${auth}`, query: sortedParams };
|
||||
}
|
||||
|
||||
// ── generic HTTPS GET ─────────────────────────────────────────────────────────
|
||||
function duoGet(creds, path, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { date, auth, query } = duoSign(creds.ikey, creds.skey, creds.host, 'GET', path, params);
|
||||
const url = `https://${creds.host}${path}${query ? '?' + query : ''}`;
|
||||
|
||||
const req = https.get(url, {
|
||||
headers: { Authorization: auth, Date: date, 'Content-Type': 'application/json' },
|
||||
}, res => {
|
||||
let body = '';
|
||||
res.on('data', d => body += d);
|
||||
res.on('end', () => {
|
||||
try { resolve({ status: res.statusCode, data: JSON.parse(body) }); }
|
||||
catch { resolve({ status: res.statusCode, data: body }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── run tests ────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
console.log('=== Duo API Connectivity Test ===\n');
|
||||
|
||||
// Validate env
|
||||
for (const [name, val] of Object.entries({ ...ACCOUNTS, ...ADMIN })) {
|
||||
if (!val) { console.error(`Missing env var for key: ${name}`); process.exit(1); }
|
||||
}
|
||||
|
||||
// 1. Accounts API — POST /accounts/v1/account/list (all Accounts API endpoints use POST)
|
||||
console.log('── Accounts API ────────────────────────────────');
|
||||
console.log(`Host : ${ACCOUNTS.host}`);
|
||||
console.log(`IKey : ${ACCOUNTS.ikey}`);
|
||||
try {
|
||||
const r = await duoPost(ACCOUNTS, '/accounts/v1/account/list');
|
||||
console.log(`HTTP : ${r.status}`);
|
||||
if (r.status === 200) {
|
||||
const accounts = r.data?.response ?? [];
|
||||
console.log(`✓ OK — ${accounts.length} child account(s) returned`);
|
||||
accounts.slice(0, 5).forEach(a => console.log(` · ${a.name} (${a.account_id}) — ${a.api_hostname}`));
|
||||
} else {
|
||||
console.log(`✗ Response:`, JSON.stringify(r.data, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`✗ Error: ${e.message}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
|
||||
// 2. Admin API — GET /admin/v1/info/summary
|
||||
console.log('── Admin API ───────────────────────────────────');
|
||||
console.log(`Host : ${ADMIN.host}`);
|
||||
console.log(`IKey : ${ADMIN.ikey}`);
|
||||
try {
|
||||
const r = await duoGet(ADMIN, '/admin/v1/info/summary');
|
||||
console.log(`HTTP : ${r.status}`);
|
||||
if (r.status === 200) {
|
||||
const s = r.data?.response ?? {};
|
||||
console.log('✓ OK — Summary:');
|
||||
console.log(` Users : ${s.user_count ?? 'n/a'}`);
|
||||
console.log(` Integrations : ${s.integration_count ?? 'n/a'}`);
|
||||
console.log(` Phones : ${s.telephony_credits_remaining ?? 'n/a'} credits remaining`);
|
||||
} else {
|
||||
console.log(`✗ Response:`, JSON.stringify(r.data, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`✗ Error: ${e.message}`);
|
||||
}
|
||||
|
||||
// 3. Admin API — GET /admin/v1/users (first page, limit 5)
|
||||
console.log();
|
||||
console.log('── Admin API — Users (first 5) ─────────────────');
|
||||
try {
|
||||
const r = await duoGet(ADMIN, '/admin/v1/users', { limit: '5', offset: '0' });
|
||||
console.log(`HTTP : ${r.status}`);
|
||||
if (r.status === 200) {
|
||||
const users = r.data?.response ?? [];
|
||||
console.log(`✓ OK — ${users.length} user(s) in page`);
|
||||
users.forEach(u => console.log(` · ${u.username} — ${u.status} — ${u.email ?? '(no email)'}`));
|
||||
} else {
|
||||
console.log(`✗ Response:`, JSON.stringify(r.data, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`✗ Error: ${e.message}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Done ===');
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Add table
Add a link
Reference in a new issue