chore(worker): add structured logging, job events, worker health endpoint and health-check script
This commit is contained in:
parent
434f33ac8f
commit
75979e7995
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env*
|
||||||
|
coverage
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Ignore local Docker config
|
||||||
|
docker-compose.override.yml
|
||||||
5
.eslintignore
Normal file
5
.eslintignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
*.min.js
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -39,3 +39,7 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# IDE settings
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|||||||
10
.npmignore
Normal file
10
.npmignore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
src/
|
||||||
|
tests/
|
||||||
|
coverage/
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
.env*
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.pem
|
||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
@ -1,23 +1,6 @@
|
|||||||
import { db } from "@/lib/db";
|
// Admin tenants route removed — use internal DB queries instead.
|
||||||
import { users } from "@/lib/db/schema/auth";
|
import { NextResponse } from 'next/server';
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { isNotNull } from "drizzle-orm";
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET() {
|
||||||
const authHeader = req.headers.get("x-api-secret");
|
return NextResponse.json({ error: 'This endpoint has been removed. Query tenants via internal admin tools.' }, { status: 410 });
|
||||||
// Wir nutzen dasselbe Secret wie für die Ingestion API
|
|
||||||
if (authHeader !== process.env.POLICY_API_SECRET) {
|
|
||||||
return new NextResponse("Unauthorized", { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hole alle einzigartigen Tenant-IDs aus der User-Tabelle
|
|
||||||
const tenants = await db
|
|
||||||
.selectDistinct({ tenantId: users.tenantId })
|
|
||||||
.from(users)
|
|
||||||
.where(isNotNull(users.tenantId));
|
|
||||||
|
|
||||||
// Wir filtern 'common' raus, falls es drin ist
|
|
||||||
const cleanList = tenants.filter(t => t.tenantId !== 'common');
|
|
||||||
|
|
||||||
return NextResponse.json(cleanList);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,133 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
// Legacy ingestion API removed in favor of BullMQ worker.
|
||||||
import { db, policySettings } from '@/lib/db';
|
// This route is intentionally kept to return 410 Gone so any external callers
|
||||||
import {
|
// (e.g., old n8n workflows) receive a clear signal to stop posting here.
|
||||||
bulkPolicySettingsSchema,
|
import { NextResponse } from 'next/server';
|
||||||
type BulkPolicySettingsInput,
|
|
||||||
} from '@/lib/validators/policySettings';
|
|
||||||
import { env } from '@/lib/env.mjs';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
|
|
||||||
/**
|
export async function POST() {
|
||||||
* POST /api/policy-settings
|
return NextResponse.json({ error: 'This endpoint has been removed. Use the new worker-based ingestion.' }, { status: 410 });
|
||||||
* Bulk upsert policy settings from n8n workflows
|
|
||||||
*
|
|
||||||
* **Security**: Requires X-API-SECRET header matching POLICY_API_SECRET env var
|
|
||||||
*/
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// T020: Validate API Secret
|
|
||||||
const apiSecret = request.headers.get('X-API-SECRET');
|
|
||||||
if (!apiSecret || apiSecret !== env.POLICY_API_SECRET) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// T022: Parse and validate request body
|
|
||||||
const body = await request.json();
|
|
||||||
const validationResult = bulkPolicySettingsSchema.safeParse(body);
|
|
||||||
|
|
||||||
if (!validationResult.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: 'Validation failed',
|
|
||||||
details: validationResult.error.issues.map((err) => ({
|
|
||||||
field: err.path.join('.'),
|
|
||||||
message: err.message,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { settings } = validationResult.data as BulkPolicySettingsInput;
|
|
||||||
|
|
||||||
// T021: Bulk upsert with onConflictDoUpdate
|
|
||||||
let upsertedCount = 0;
|
|
||||||
for (const setting of settings) {
|
|
||||||
await db
|
|
||||||
.insert(policySettings)
|
|
||||||
.values({
|
|
||||||
tenantId: setting.tenantId,
|
|
||||||
policyName: setting.policyName,
|
|
||||||
policyType: setting.policyType,
|
|
||||||
settingName: setting.settingName,
|
|
||||||
settingValue: setting.settingValue,
|
|
||||||
graphPolicyId: setting.graphPolicyId,
|
|
||||||
lastSyncedAt: new Date(),
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [
|
|
||||||
policySettings.tenantId,
|
|
||||||
policySettings.graphPolicyId,
|
|
||||||
policySettings.settingName,
|
|
||||||
],
|
|
||||||
set: {
|
|
||||||
policyName: setting.policyName,
|
|
||||||
policyType: setting.policyType,
|
|
||||||
settingValue: setting.settingValue,
|
|
||||||
lastSyncedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
upsertedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
upsertedCount,
|
|
||||||
message: `${upsertedCount} settings upserted successfully`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Policy settings upsert failed:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function DELETE() {
|
||||||
* DELETE /api/policy-settings?tenantId=xxx
|
return NextResponse.json({ error: 'This endpoint has been removed.' }, { status: 410 });
|
||||||
* Delete all policy settings for a tenant
|
|
||||||
*
|
|
||||||
* **Security**: Requires X-API-SECRET header
|
|
||||||
*/
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// T024: Validate API Secret
|
|
||||||
const apiSecret = request.headers.get('X-API-SECRET');
|
|
||||||
if (!apiSecret || apiSecret !== env.POLICY_API_SECRET) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Unauthorized' },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const tenantId = searchParams.get('tenantId');
|
|
||||||
|
|
||||||
if (!tenantId) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'tenantId query parameter is required' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await db
|
|
||||||
.delete(policySettings)
|
|
||||||
.where(eq(policySettings.tenantId, tenantId));
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
deletedCount: result.rowCount ?? 0,
|
|
||||||
message: `${result.rowCount ?? 0} settings deleted for tenant`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Policy settings deletion failed:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
44
app/api/policy-sync/status/[jobId]/route.ts
Normal file
44
app/api/policy-sync/status/[jobId]/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { syncQueue } from '@/lib/queue/syncQueue';
|
||||||
|
import { getUserAuth } from '@/lib/auth/utils';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { jobId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { session } = await getUserAuth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = params.jobId;
|
||||||
|
const job = await syncQueue.getJob(jobId);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await job.getState();
|
||||||
|
const progress = job.progress;
|
||||||
|
const returnvalue = job.returnvalue;
|
||||||
|
const failedReason = job.failedReason;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
jobId: job.id,
|
||||||
|
state,
|
||||||
|
progress,
|
||||||
|
result: returnvalue,
|
||||||
|
error: failedReason,
|
||||||
|
processedOn: job.processedOn,
|
||||||
|
finishedOn: job.finishedOn,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get job status:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to retrieve job status' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/policy-sync/status/route.ts
Normal file
51
app/api/policy-sync/status/route.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getUserAuth } from '@/lib/auth/utils';
|
||||||
|
import { syncQueue } from '@/lib/queue/syncQueue';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { session } = await getUserAuth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const jobId = searchParams.get('jobId');
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
return NextResponse.json({ error: 'Job ID required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get job from BullMQ
|
||||||
|
const job = await syncQueue.getJob(jobId);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get job state
|
||||||
|
const state = await job.getState();
|
||||||
|
const progress = job.progress;
|
||||||
|
const returnValue = job.returnvalue;
|
||||||
|
const failedReason = job.failedReason;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
jobId: job.id,
|
||||||
|
state,
|
||||||
|
progress,
|
||||||
|
data: job.data,
|
||||||
|
result: returnValue,
|
||||||
|
failedReason,
|
||||||
|
timestamp: job.timestamp,
|
||||||
|
processedOn: job.processedOn,
|
||||||
|
finishedOn: job.finishedOn,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching job status:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch job status' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/api/worker-health/route.ts
Normal file
26
app/api/worker-health/route.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import checkHealth from '../../../worker/health';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const health = checkHealth();
|
||||||
|
|
||||||
|
const redisUrl = process.env.REDIS_URL;
|
||||||
|
let queueInfo = null;
|
||||||
|
|
||||||
|
if (redisUrl) {
|
||||||
|
const connection = new Redis(redisUrl);
|
||||||
|
const queue = new Queue('intune-sync-queue', { connection });
|
||||||
|
const counts = await queue.getJobCounts();
|
||||||
|
queueInfo = counts;
|
||||||
|
await queue.close();
|
||||||
|
await connection.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, health, queue: queueInfo, timestamp: new Date().toISOString() });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ ok: false, error: err?.message || String(err) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,14 +8,16 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export function SyncButton() {
|
export function SyncButton() {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [lastJobId, setLastJobId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSync = () => {
|
const handleSync = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await triggerPolicySync();
|
const result = await triggerPolicySync();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success && result.jobId) {
|
||||||
toast.success(result.message ?? 'Policy sync triggered successfully');
|
setLastJobId(result.jobId);
|
||||||
|
toast.success(result.message ?? `Sync queued (Job #${result.jobId})`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error ?? 'Failed to trigger sync');
|
toast.error(result.error ?? 'Failed to trigger sync');
|
||||||
}
|
}
|
||||||
@ -26,6 +28,7 @@ export function SyncButton() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
@ -35,7 +38,7 @@ export function SyncButton() {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Syncing...
|
Queuing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -44,5 +47,11 @@ export function SyncButton() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{lastJobId && (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Last job: #{lastJobId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import { db, policySettings, type PolicySetting } from '@/lib/db';
|
|||||||
import { getUserAuth } from '@/lib/auth/utils';
|
import { getUserAuth } from '@/lib/auth/utils';
|
||||||
import { eq, ilike, or, desc, and, ne, isNotNull } from 'drizzle-orm';
|
import { eq, ilike, or, desc, and, ne, isNotNull } from 'drizzle-orm';
|
||||||
import { env } from '@/lib/env.mjs';
|
import { env } from '@/lib/env.mjs';
|
||||||
|
import { syncQueue } from '@/lib/queue/syncQueue';
|
||||||
|
import { syncQueue } from '@/lib/queue/syncQueue';
|
||||||
|
|
||||||
export interface PolicySettingSearchResult {
|
export interface PolicySettingSearchResult {
|
||||||
id: string;
|
id: string;
|
||||||
@ -353,16 +355,16 @@ export async function seedMyTenantData(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger manual policy sync via n8n webhook
|
* Trigger manual policy sync via BullMQ worker
|
||||||
*
|
*
|
||||||
* **Security**: This function enforces tenant isolation by:
|
* **Security**: This function enforces tenant isolation by:
|
||||||
* 1. Validating user session via getUserAuth()
|
* 1. Validating user session via getUserAuth()
|
||||||
* 2. Extracting tenantId from session
|
* 2. Extracting tenantId from session
|
||||||
* 3. Sending only the authenticated user's tenantId to n8n
|
* 3. Enqueuing a job with only the authenticated user's tenantId
|
||||||
*
|
*
|
||||||
* @returns Success/error result
|
* @returns Success/error result with job ID
|
||||||
*/
|
*/
|
||||||
export async function triggerPolicySync(): Promise<{ success: boolean; message?: string; error?: string }> {
|
export async function triggerPolicySync(): Promise<{ success: boolean; message?: string; error?: string; jobId?: string }> {
|
||||||
try {
|
try {
|
||||||
const { session } = await getUserAuth();
|
const { session } = await getUserAuth();
|
||||||
|
|
||||||
@ -375,37 +377,24 @@ export async function triggerPolicySync(): Promise<{ success: boolean; message?:
|
|||||||
return { success: false, error: 'No tenant ID found in session' };
|
return { success: false, error: 'No tenant ID found in session' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookUrl = env.N8N_SYNC_WEBHOOK_URL;
|
// Enqueue sync job to BullMQ
|
||||||
if (!webhookUrl) {
|
const job = await syncQueue.add('sync-tenant', {
|
||||||
return { success: false, error: 'Sync webhook not configured' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger n8n workflow
|
|
||||||
const response = await fetch(webhookUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
tenantId,
|
tenantId,
|
||||||
source: 'manual_trigger',
|
source: 'manual_trigger',
|
||||||
triggeredAt: new Date().toISOString(),
|
triggeredAt: new Date().toISOString(),
|
||||||
}),
|
triggeredBy: session.user.email || session.user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Webhook responded with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Policy sync triggered successfully',
|
message: `Policy sync queued successfully (Job #${job.id})`,
|
||||||
|
jobId: job.id,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to trigger policy sync:', error);
|
console.error('Failed to trigger policy sync:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Failed to trigger sync. Please try again later.',
|
error: 'Failed to queue sync job. Please try again later.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export const env = createEnv({
|
|||||||
NEXTAUTH_URL: z.string().optional(),
|
NEXTAUTH_URL: z.string().optional(),
|
||||||
|
|
||||||
// Azure AD (Microsoft Entra ID) - optional in development
|
// Azure AD (Microsoft Entra ID) - optional in development
|
||||||
|
AZURE_AD_TENANT_ID: z.string().optional(),
|
||||||
AZURE_AD_CLIENT_ID: z.string().optional(),
|
AZURE_AD_CLIENT_ID: z.string().optional(),
|
||||||
AZURE_AD_CLIENT_SECRET: z.string().optional(),
|
AZURE_AD_CLIENT_SECRET: z.string().optional(),
|
||||||
|
|
||||||
@ -21,11 +22,8 @@ export const env = createEnv({
|
|||||||
STRIPE_SECRET_KEY: z.string().optional(),
|
STRIPE_SECRET_KEY: z.string().optional(),
|
||||||
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
||||||
|
|
||||||
// Policy Settings Ingestion API
|
// Redis used by BullMQ worker
|
||||||
POLICY_API_SECRET: z.string().optional(),
|
REDIS_URL: z.string().url().optional(),
|
||||||
|
|
||||||
// n8n Webhook for manual policy sync
|
|
||||||
N8N_SYNC_WEBHOOK_URL: z.string().optional(),
|
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
||||||
|
|||||||
9
lib/queue/redis.ts
Normal file
9
lib/queue/redis.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
const redisUrl = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
|
||||||
|
|
||||||
|
// ioredis default `maxRetriesPerRequest` is not null; BullMQ requires it to be null.
|
||||||
|
// Create a shared connection with `maxRetriesPerRequest: null` to be compatible with BullMQ.
|
||||||
|
export const redisConnection = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
||||||
|
|
||||||
|
export default redisConnection;
|
||||||
9
lib/queue/syncQueue.ts
Normal file
9
lib/queue/syncQueue.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import redisConnection from './redis';
|
||||||
|
|
||||||
|
// Export a shared queue instance used by the app to enqueue sync jobs
|
||||||
|
export const syncQueue = new Queue('intune-sync-queue', {
|
||||||
|
connection: redisConnection as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default syncQueue;
|
||||||
770
package-lock.json
generated
770
package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.34.3",
|
"@auth/core": "^0.34.3",
|
||||||
"@auth/drizzle-adapter": "^1.11.1",
|
"@auth/drizzle-adapter": "^1.11.1",
|
||||||
|
"@azure/identity": "^4.0.0",
|
||||||
"@paralleldrive/cuid2": "^3.0.4",
|
"@paralleldrive/cuid2": "^3.0.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@ -18,11 +19,13 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@stripe/stripe-js": "^8.5.2",
|
"@stripe/stripe-js": "^8.5.2",
|
||||||
"@t3-oss/env-nextjs": "^0.13.8",
|
"@t3-oss/env-nextjs": "^0.13.8",
|
||||||
|
"bullmq": "^5.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"drizzle-zod": "^0.8.3",
|
"drizzle-zod": "^0.8.3",
|
||||||
|
"ioredis": "^5.3.0",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
@ -176,6 +179,164 @@
|
|||||||
"preact": ">=10"
|
"preact": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@azure/abort-controller": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/core-auth": {
|
||||||
|
"version": "1.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz",
|
||||||
|
"integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/abort-controller": "^2.1.2",
|
||||||
|
"@azure/core-util": "^1.13.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/core-client": {
|
||||||
|
"version": "1.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
|
||||||
|
"integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/abort-controller": "^2.1.2",
|
||||||
|
"@azure/core-auth": "^1.10.0",
|
||||||
|
"@azure/core-rest-pipeline": "^1.22.0",
|
||||||
|
"@azure/core-tracing": "^1.3.0",
|
||||||
|
"@azure/core-util": "^1.13.0",
|
||||||
|
"@azure/logger": "^1.3.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/core-rest-pipeline": {
|
||||||
|
"version": "1.22.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz",
|
||||||
|
"integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/abort-controller": "^2.1.2",
|
||||||
|
"@azure/core-auth": "^1.10.0",
|
||||||
|
"@azure/core-tracing": "^1.3.0",
|
||||||
|
"@azure/core-util": "^1.13.0",
|
||||||
|
"@azure/logger": "^1.3.0",
|
||||||
|
"@typespec/ts-http-runtime": "^0.3.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/core-tracing": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/core-util": {
|
||||||
|
"version": "1.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz",
|
||||||
|
"integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/abort-controller": "^2.1.2",
|
||||||
|
"@typespec/ts-http-runtime": "^0.3.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/identity": {
|
||||||
|
"version": "4.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz",
|
||||||
|
"integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/abort-controller": "^2.0.0",
|
||||||
|
"@azure/core-auth": "^1.9.0",
|
||||||
|
"@azure/core-client": "^1.9.2",
|
||||||
|
"@azure/core-rest-pipeline": "^1.17.0",
|
||||||
|
"@azure/core-tracing": "^1.0.0",
|
||||||
|
"@azure/core-util": "^1.11.0",
|
||||||
|
"@azure/logger": "^1.0.0",
|
||||||
|
"@azure/msal-browser": "^4.2.0",
|
||||||
|
"@azure/msal-node": "^3.5.0",
|
||||||
|
"open": "^10.1.0",
|
||||||
|
"tslib": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/logger": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typespec/ts-http-runtime": "^0.3.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/msal-browser": {
|
||||||
|
"version": "4.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.27.0.tgz",
|
||||||
|
"integrity": "sha512-bZ8Pta6YAbdd0o0PEaL1/geBsPrLEnyY/RDWqvF1PP9RUH8EMLvUMGoZFYS6jSlUan6KZ9IMTLCnwpWWpQRK/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/msal-common": "15.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/msal-common": {
|
||||||
|
"version": "15.13.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.3.tgz",
|
||||||
|
"integrity": "sha512-shSDU7Ioecya+Aob5xliW9IGq1Ui8y4EVSdWGyI1Gbm4Vg61WpP95LuzcY214/wEjSn6w4PZYD4/iVldErHayQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@azure/msal-node": {
|
||||||
|
"version": "3.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.4.tgz",
|
||||||
|
"integrity": "sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/msal-common": "15.13.3",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"uuid": "^8.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@ -2044,6 +2205,12 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@ -2094,6 +2261,84 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@ -4214,6 +4459,20 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typespec/ts-http-runtime": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"http-proxy-agent": "^7.0.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
||||||
@ -4507,6 +4766,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "7.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@ -4878,6 +5146,12 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@ -4885,6 +5159,61 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bullmq": {
|
||||||
|
"version": "5.65.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.65.1.tgz",
|
||||||
|
"integrity": "sha512-QgDAzX1G9L5IRy4Orva5CfQTXZT+5K+OfO/kbPrAqN+pmL9LJekCzxijXehlm/u2eXfWPfWvIdJJIqiuz3WJSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "^4.9.0",
|
||||||
|
"ioredis": "^5.8.2",
|
||||||
|
"msgpackr": "^1.11.2",
|
||||||
|
"node-abort-controller": "^3.1.1",
|
||||||
|
"semver": "^7.5.4",
|
||||||
|
"tslib": "^2.0.0",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/semver": {
|
||||||
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/uuid": {
|
||||||
|
"version": "11.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bundle-name": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"run-applescript": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||||
@ -5007,6 +5336,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@ -5050,6 +5388,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -5147,7 +5497,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@ -5168,6 +5517,34 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/default-browser": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bundle-name": "^4.1.0",
|
||||||
|
"default-browser-id": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/default-browser-id": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/define-data-property": {
|
"node_modules/define-data-property": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
@ -5186,6 +5563,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/define-lazy-prop": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/define-properties": {
|
"node_modules/define-properties": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||||
@ -5204,6 +5593,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@ -5412,6 +5810,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.255",
|
"version": "1.5.255",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.255.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.255.tgz",
|
||||||
@ -6608,6 +7015,32 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-proxy-agent": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.0",
|
||||||
|
"debug": "^4.3.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "^7.1.2",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@ -6660,6 +7093,30 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "5.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
|
||||||
|
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "1.4.0",
|
||||||
|
"cluster-key-slot": "^1.1.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"lodash.defaults": "^4.2.0",
|
||||||
|
"lodash.isarguments": "^3.1.0",
|
||||||
|
"redis-errors": "^1.2.0",
|
||||||
|
"redis-parser": "^3.0.0",
|
||||||
|
"standard-as-callback": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.22.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@ -6818,6 +7275,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-docker": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"is-docker": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@ -6877,6 +7349,24 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-inside-container": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-docker": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"is-inside-container": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-map": {
|
"node_modules/is-map": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
|
||||||
@ -7075,6 +7565,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-wsl": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-inside-container": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||||
@ -7193,6 +7698,40 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonwebtoken/node_modules/semver": {
|
||||||
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsx-ast-utils": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||||
@ -7209,6 +7748,27 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@ -7530,6 +8090,54 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.defaults": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isarguments": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@ -7537,6 +8145,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@ -7569,6 +8183,15 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@ -7639,9 +8262,39 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "1.11.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
|
||||||
|
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
|
||||||
@ -7841,6 +8494,27 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abort-controller": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
@ -8003,6 +8677,24 @@
|
|||||||
"node": "^10.13.0 || >=12.0.0"
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/open": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"default-browser": "^5.2.1",
|
||||||
|
"define-lazy-prop": "^3.0.0",
|
||||||
|
"is-inside-container": "^1.0.0",
|
||||||
|
"wsl-utils": "^0.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/openid-client": {
|
"node_modules/openid-client": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
@ -8561,6 +9253,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redis-parser": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"redis-errors": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@ -8683,6 +9396,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/run-applescript": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/run-parallel": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
@ -8727,6 +9452,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@ -9036,6 +9781,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stop-iteration-iterator": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||||
@ -9887,6 +10638,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wsl-utils": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-wsl": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@ -15,7 +15,8 @@
|
|||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:studio:prod": "lsof -ti:5433 | xargs kill -9 2>/dev/null; ssh cloudarix \"docker rm -f db-proxy 2>/dev/null; IP=\\$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tenantpilot-db-tav83h.1.8ijze7mxpcg69pvdlu0g4j4et); docker run --rm -d --name db-proxy --network dokploy-network -p 127.0.0.1:5433:5432 alpine/socat TCP-LISTEN:5432,fork TCP:\\$IP:5432\"; ssh -L 5433:127.0.0.1:5433 cloudarix -N & sleep 3 && DATABASE_URL='postgresql://postgres:JsdPCZiC1C56Sz@localhost:5433/postgres' drizzle-kit studio",
|
"db:studio:prod": "lsof -ti:5433 | xargs kill -9 2>/dev/null; ssh cloudarix \"docker rm -f db-proxy 2>/dev/null; IP=\\$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tenantpilot-db-tav83h.1.8ijze7mxpcg69pvdlu0g4j4et); docker run --rm -d --name db-proxy --network dokploy-network -p 127.0.0.1:5433:5432 alpine/socat TCP-LISTEN:5432,fork TCP:\\$IP:5432\"; ssh -L 5433:127.0.0.1:5433 cloudarix -N & sleep 3 && DATABASE_URL='postgresql://postgres:JsdPCZiC1C56Sz@localhost:5433/postgres' drizzle-kit studio",
|
||||||
"db:check": "drizzle-kit check",
|
"db:check": "drizzle-kit check",
|
||||||
"stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks/stripe"
|
"stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks/stripe",
|
||||||
|
"worker:start": "tsx ./worker/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.34.3",
|
"@auth/core": "^0.34.3",
|
||||||
@ -44,6 +45,9 @@
|
|||||||
"resend": "^6.5.0",
|
"resend": "^6.5.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"stripe": "^20.0.0",
|
"stripe": "^20.0.0",
|
||||||
|
"bullmq": "^5.0.0",
|
||||||
|
"ioredis": "^5.3.0",
|
||||||
|
"@azure/identity": "^4.0.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"use-debounce": "^10.0.6",
|
"use-debounce": "^10.0.6",
|
||||||
@ -65,3 +69,4 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
scripts/check-worker-health.js
Normal file
25
scripts/check-worker-health.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
const { Queue } = require('bullmq');
|
||||||
|
|
||||||
|
async function main(){
|
||||||
|
const health = { ok: true, redisUrlPresent: !!process.env.REDIS_URL, timestamp: new Date().toISOString() };
|
||||||
|
console.log('health', health);
|
||||||
|
|
||||||
|
if (!process.env.REDIS_URL) {
|
||||||
|
console.error('No REDIS_URL set in environment');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = new Redis(process.env.REDIS_URL);
|
||||||
|
const q = new Queue('intune-sync-queue', { connection: conn });
|
||||||
|
const counts = await q.getJobCounts();
|
||||||
|
console.log('queue', counts);
|
||||||
|
await q.close();
|
||||||
|
await conn.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('health-check-error', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
56
scripts/test-graph-connection.ts
Normal file
56
scripts/test-graph-connection.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Smoke test script for Graph API connectivity
|
||||||
|
* Tests token acquisition and basic fetch from Microsoft Graph
|
||||||
|
*
|
||||||
|
* Usage: tsx scripts/test-graph-connection.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { getGraphAccessToken } from '../worker/jobs/graphAuth';
|
||||||
|
import { fetchFromGraph } from '../worker/jobs/graphFetch';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== Graph API Smoke Test ===\n');
|
||||||
|
|
||||||
|
// Check required env vars
|
||||||
|
const required = ['AZURE_AD_TENANT_ID', 'AZURE_AD_CLIENT_ID', 'AZURE_AD_CLIENT_SECRET'];
|
||||||
|
const missing = required.filter(key => !process.env[key]);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error('❌ Missing required environment variables:', missing.join(', '));
|
||||||
|
console.error('\nPlease set these in your .env file:');
|
||||||
|
missing.forEach(key => console.error(` ${key}=your_value_here`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: Token acquisition
|
||||||
|
console.log('Test 1: Acquiring Graph access token...');
|
||||||
|
const token = await getGraphAccessToken();
|
||||||
|
console.log('✓ Token acquired successfully (length:', token.length, 'chars)\n');
|
||||||
|
|
||||||
|
// Test 2: Fetch device configurations
|
||||||
|
console.log('Test 2: Fetching device configurations...');
|
||||||
|
const configs = await fetchFromGraph('/deviceManagement/deviceConfigurations');
|
||||||
|
console.log(`✓ Fetched ${configs.length} device configuration(s)\n`);
|
||||||
|
|
||||||
|
// Test 3: Fetch compliance policies
|
||||||
|
console.log('Test 3: Fetching compliance policies...');
|
||||||
|
const compliance = await fetchFromGraph('/deviceManagement/deviceCompliancePolicies');
|
||||||
|
console.log(`✓ Fetched ${compliance.length} compliance policy/policies\n`);
|
||||||
|
|
||||||
|
console.log('=== All tests passed ✓ ===');
|
||||||
|
console.log(`\nTotal policies found: ${configs.length + compliance.length}`);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Test failed:', error instanceof Error ? error.message : String(error));
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
console.error('\nStack trace:');
|
||||||
|
console.error(error.stack);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
17
scripts/test-queue-connection.js
Normal file
17
scripts/test-queue-connection.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const { Queue } = require('bullmq');
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
|
||||||
|
async function main(){
|
||||||
|
const redisUrl = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
|
||||||
|
console.log('Using REDIS_URL=', redisUrl);
|
||||||
|
const connection = new Redis(redisUrl);
|
||||||
|
const queue = new Queue('intune-sync-queue', { connection });
|
||||||
|
|
||||||
|
const job = await queue.add('sync-tenant', { tenantId: 'test-tenant' });
|
||||||
|
console.log('Enqueued job id=', job.id);
|
||||||
|
await connection.quit();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err=>{ console.error(err); process.exit(1); });
|
||||||
95
scripts/test-ui-sync-flow.ts
Normal file
95
scripts/test-ui-sync-flow.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* End-to-end test: Simulate UI sync flow
|
||||||
|
* - Start worker (or use existing)
|
||||||
|
* - Trigger sync via syncQueue (like UI does)
|
||||||
|
* - Monitor job status
|
||||||
|
* - Verify database updates
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { syncQueue } from '../lib/queue/syncQueue';
|
||||||
|
import { db } from '../lib/db';
|
||||||
|
import { policySettings } from '../lib/db/schema/policySettings';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
async function simulateUISync() {
|
||||||
|
console.log('=== UI Sync Flow Test ===\n');
|
||||||
|
|
||||||
|
const tenantId = 'ui-test-tenant';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Enqueue job (like UI button does)
|
||||||
|
console.log('1. Enqueueing sync job (simulating UI click)...');
|
||||||
|
const job = await syncQueue.add('sync-tenant', {
|
||||||
|
tenantId,
|
||||||
|
source: 'manual_trigger',
|
||||||
|
triggeredAt: new Date().toISOString(),
|
||||||
|
triggeredBy: 'test-user@example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ Job queued: #${job.id}\n`);
|
||||||
|
|
||||||
|
// Step 2: Monitor job status
|
||||||
|
console.log('2. Monitoring job status...');
|
||||||
|
let state = await job.getState();
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 60; // 60 seconds timeout
|
||||||
|
|
||||||
|
while (state !== 'completed' && state !== 'failed' && attempts < maxAttempts) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
state = await job.getState();
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
if (attempts % 5 === 0) {
|
||||||
|
console.log(` Job state: ${state} (${attempts}s elapsed)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'completed') {
|
||||||
|
console.log(` ✓ Job completed after ${attempts}s\n`);
|
||||||
|
|
||||||
|
const result = job.returnvalue;
|
||||||
|
console.log('3. Job result:');
|
||||||
|
console.log(` Policies found: ${result?.policiesFound || 0}`);
|
||||||
|
console.log(` Settings upserted: ${result?.settingsUpserted || 0}\n`);
|
||||||
|
} else if (state === 'failed') {
|
||||||
|
console.log(` ✗ Job failed: ${job.failedReason}\n`);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠ Job timeout (state: ${state})\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Verify database
|
||||||
|
console.log('4. Verifying database updates...');
|
||||||
|
const settings = await db
|
||||||
|
.select()
|
||||||
|
.from(policySettings)
|
||||||
|
.where(eq(policySettings.tenantId, tenantId))
|
||||||
|
.orderBy(desc(policySettings.lastSyncedAt))
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
console.log(` ✓ Found ${settings.length} settings in database\n`);
|
||||||
|
|
||||||
|
if (settings.length > 0) {
|
||||||
|
console.log('5. Sample settings:');
|
||||||
|
settings.slice(0, 3).forEach((s, i) => {
|
||||||
|
console.log(` ${i + 1}. ${s.policyName} → ${s.settingName}`);
|
||||||
|
console.log(` Value: ${s.settingValue.substring(0, 60)}${s.settingValue.length > 60 ? '...' : ''}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Test Complete ✓ ===');
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n✗ Test failed:', error instanceof Error ? error.message : String(error));
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
console.error('\nStack:', error.stack);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateUISync();
|
||||||
28
scripts/verify-sync.ts
Normal file
28
scripts/verify-sync.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { db } from '../lib/db';
|
||||||
|
import { policySettings } from '../lib/db/schema/policySettings';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== Querying policy_settings for test-tenant ===\n');
|
||||||
|
|
||||||
|
const results = await db
|
||||||
|
.select()
|
||||||
|
.from(policySettings)
|
||||||
|
.where(eq(policySettings.tenantId, 'test-tenant'))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
console.log(`Found ${results.length} settings:\n`);
|
||||||
|
|
||||||
|
results.forEach((setting, idx) => {
|
||||||
|
console.log(`${idx + 1}. ${setting.policyName}`);
|
||||||
|
console.log(` Type: ${setting.policyType}`);
|
||||||
|
console.log(` Setting: ${setting.settingName}`);
|
||||||
|
console.log(` Value: ${setting.settingValue.substring(0, 80)}${setting.settingValue.length > 80 ? '...' : ''}`);
|
||||||
|
console.log(` Last synced: ${setting.lastSyncedAt}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
34
specs/004-policy-explorer-v2/checklists/requirements.md
Normal file
34
specs/004-policy-explorer-v2/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Policy Explorer V2
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2025-12-08
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [ ] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [ ] Focused on user value and business needs
|
||||||
|
- [ ] Written for non-technical stakeholders
|
||||||
|
- [ ] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [ ] Requirements are testable and unambiguous
|
||||||
|
- [ ] Success criteria are measurable
|
||||||
|
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [ ] All acceptance scenarios are defined
|
||||||
|
- [ ] Edge cases are identified
|
||||||
|
- [ ] Scope is clearly bounded
|
||||||
|
- [ ] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [ ] All functional requirements have clear acceptance criteria
|
||||||
|
- [ ] User scenarios cover primary flows
|
||||||
|
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [ ] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
206
specs/004-policy-explorer-v2/spec.md
Normal file
206
specs/004-policy-explorer-v2/spec.md
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
# Feature Specification: Policy Explorer V2
|
||||||
|
|
||||||
|
**Feature Branch**: `004-policy-explorer-v2`
|
||||||
|
**Created**: 2025-12-08
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: "Policy Explorer V2 - Advanced data table with pagination, sorting, filtering, column management, bulk export, and enhanced detail view for Intune policy settings analysis"
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Erweiterung des bestehenden Policy Explorers (`/search`) von einer einfachen Such-/Tabellenansicht zu einem vollwertigen Admin-Interface für die Analyse großer Policy-Datasets mit erweiterten Funktionen für Navigation, Filterung, Sortierung und Export.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Advanced Data Table Navigation (Priority: P1)
|
||||||
|
|
||||||
|
Als Intune-Admin möchte ich durch große Policy-Datasets navigieren können mit Pagination, Sortierung und anpassbaren Spalten, um schnell relevante Settings zu finden.
|
||||||
|
|
||||||
|
**Why this priority**: Grundlegende Tabellenfunktionalität ist essentiell für die Arbeit mit >100 Settings. Ohne Pagination und Sortierung wird die Tabelle unbrauchbar.
|
||||||
|
|
||||||
|
**Independent Test**: Dataset mit 500+ Settings laden, Pagination durchklicken, nach verschiedenen Spalten sortieren, Spalten ein-/ausblenden.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** der Admin hat 200+ Policy Settings im System, **When** er den Policy Explorer öffnet, **Then** sieht er maximal 50 Settings pro Seite mit Pagination Controls (Previous, Next, Page Numbers).
|
||||||
|
2. **Given** der Admin sieht die Tabelle, **When** er auf einen Spalten-Header klickt, **Then** wird die Tabelle nach dieser Spalte sortiert (Toggle: ASC/DESC).
|
||||||
|
3. **Given** der Admin sieht die Tabelle, **When** er das Column Visibility Menu öffnet und Spalten deselektiert, **Then** werden diese Spalten ausgeblendet und die Einstellung bleibt beim Reload erhalten (localStorage).
|
||||||
|
4. **Given** der Admin sieht die Tabelle, **When** er eine Spaltenbreite mit der Maus anpasst, **Then** wird die neue Breite gespeichert (localStorage).
|
||||||
|
5. **Given** der Admin scrollt nach unten, **When** er weiter scrollt, **Then** bleibt der Table Header sichtbar (sticky).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Enhanced Filtering (Priority: P1)
|
||||||
|
|
||||||
|
Als Intune-Admin möchte ich nach PolicyType filtern können, um nur relevante Policy-Kategorien zu sehen.
|
||||||
|
|
||||||
|
**Why this priority**: Filter sind essentiell für die Arbeit mit verschiedenen Policy-Typen (Compliance, Security, Configuration).
|
||||||
|
|
||||||
|
**Independent Test**: PolicyType Filter auswählen, Ergebnisse validieren, Filter kombinieren mit Suche.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** der Admin öffnet den Policy Explorer, **When** er den PolicyType Filter öffnet, **Then** sieht er alle verfügbaren Policy Types als Checkboxen (deviceConfiguration, compliancePolicy, etc.).
|
||||||
|
2. **Given** der Admin hat einen PolicyType ausgewählt, **When** die Tabelle lädt, **Then** werden nur Settings dieses Typs angezeigt.
|
||||||
|
3. **Given** der Admin hat Filter + Suche kombiniert, **When** er die Seite neu lädt, **Then** bleiben Filter und Suche aktiv (URL state).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Bulk Export (Priority: P1)
|
||||||
|
|
||||||
|
Als Intune-Admin möchte ich Policy Settings als CSV exportieren können, um sie in Excel/Sheets zu analysieren oder zu dokumentieren.
|
||||||
|
|
||||||
|
**Why this priority**: Export ist ein häufig gefordertes Feature für Compliance-Reports und Dokumentation.
|
||||||
|
|
||||||
|
**Independent Test**: Rows selektieren, CSV Export triggern, Datei öffnen und Inhalt validieren.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** der Admin hat Rows in der Tabelle selektiert, **When** er "Export Selected" klickt, **Then** wird ein CSV mit den selektierten Rows heruntergeladen.
|
||||||
|
2. **Given** der Admin hat Filter angewendet, **When** er "Export All Filtered" klickt, **Then** wird ein CSV mit allen gefilterten Results heruntergeladen (serverseitig generiert).
|
||||||
|
3. **Given** das CSV wurde generiert, **When** der Admin die Datei öffnet, **Then** enthält sie alle relevanten Spalten (Policy Name, Type, Setting Name, Value, Last Synced) mit korrektem CSV-Escaping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Enhanced Detail View (Priority: P2)
|
||||||
|
|
||||||
|
Als Intune-Admin möchte ich im Detail-Sheet erweiterte Funktionen haben (Copy-to-Clipboard, Raw JSON View), um Settings schnell zu teilen oder zu debuggen.
|
||||||
|
|
||||||
|
**Why this priority**: Quality-of-Life Verbesserung für Power-User. Nicht MVP-blocking aber sehr nützlich.
|
||||||
|
|
||||||
|
**Independent Test**: Detail-Sheet öffnen, Copy-Buttons testen, Raw JSON Tab öffnen.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** der Admin öffnet das Detail-Sheet für ein Setting, **When** er auf "Copy Policy ID" klickt, **Then** wird die graphPolicyId in die Zwischenablage kopiert.
|
||||||
|
2. **Given** der Admin sieht das Detail-Sheet, **When** er auf "Raw JSON" Tab klickt, **Then** sieht er die kompletten Rohdaten formatiert als JSON.
|
||||||
|
3. **Given** das Setting hat eine graphPolicyId, **When** der Admin "Open in Intune" klickt, **Then** öffnet sich ein neues Tab mit dem Intune Portal Link (oder wird Policy ID kopiert als Fallback).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Was passiert bei sehr breiten Tabellen (viele Spalten)? → Horizontales Scrolling + Sticky erste Spalte.
|
||||||
|
- Wie verhält sich Export bei >10.000 Rows? → Server-seitige Limitierung auf max 5000 Rows pro Export mit Warning.
|
||||||
|
- Was passiert wenn keine Rows selektiert sind beim Export? → Button disabled mit Tooltip "Select rows first".
|
||||||
|
- Wie wird Sorting mit Pagination kombiniert? → Server-seitig: Sort state wird an API übergeben.
|
||||||
|
- Was passiert bei Column Resize auf mobilen Geräten? → Touch-optimierte Resize Handles oder Feature deaktiviert auf <768px.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUSS serverseitige Pagination mit konfigurierbarer Page Size (10/25/50/100) unterstützen.
|
||||||
|
- **FR-002**: System MUSS Sortierung nach allen Spalten unterstützen (ASC/DESC Toggle).
|
||||||
|
- **FR-003**: System MUSS Column Visibility Management bereitstellen (Spalten ein-/ausblenden via Dropdown).
|
||||||
|
- **FR-004**: System MUSS Column Resizing unterstützen (Drag & Drop an Spaltenrändern).
|
||||||
|
- **FR-005**: System MUSS Sticky Table Header implementieren (bleibt beim Scrollen sichtbar).
|
||||||
|
- **FR-006**: System MUSS PolicyType Filter als Multi-Select Checkbox implementieren.
|
||||||
|
- **FR-007**: System MUSS Filter + Suche kombinierbar machen (beide aktiv gleichzeitig).
|
||||||
|
- **FR-008**: System MUSS URL State für Filter/Sort/Search speichern (shareable links).
|
||||||
|
- **FR-009**: System MUSS localStorage für Column Settings verwenden (Breite, Visibility, Reihenfolge).
|
||||||
|
- **FR-010**: System MUSS Row Selection mit Checkboxes implementieren (einzeln + Select All).
|
||||||
|
- **FR-011**: System MUSS CSV Export für Selected Rows bereitstellen (client-seitig generiert).
|
||||||
|
- **FR-012**: System MUSS CSV Export für All Filtered Results bereitstellen (server-seitig, max 5000 Rows).
|
||||||
|
- **FR-013**: System MUSS Copy-to-Clipboard Buttons für Policy ID, Setting Name, Setting Value implementieren.
|
||||||
|
- **FR-014**: System MUSS Raw JSON View im Detail-Sheet anzeigen.
|
||||||
|
- **FR-015**: System MUSS "Open in Intune" Button implementieren (wenn graphPolicyId vorhanden).
|
||||||
|
- **FR-016**: System MUSS Truncation + Tooltip für lange Werte/IDs implementieren.
|
||||||
|
- **FR-017**: System MUSS Compact/Comfortable Density Mode Toggle bereitstellen.
|
||||||
|
- **FR-018**: System MUSS Meta-Info Zeile über Tabelle anzeigen (X settings · Y policies · Last sync).
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
Erweitert `PolicySetting` Datenmodell (keine Schema-Änderungen nötig):
|
||||||
|
|
||||||
|
- **DataTableState**: Client-side State für Tabelle
|
||||||
|
- `pagination`: { page: number, pageSize: number }
|
||||||
|
- `sorting`: { column: string, direction: 'asc'|'desc' }[]
|
||||||
|
- `columnVisibility`: { [columnId: string]: boolean }
|
||||||
|
- `columnSizing`: { [columnId: string]: number }
|
||||||
|
- `rowSelection`: { [rowId: string]: boolean }
|
||||||
|
|
||||||
|
- **FilterState**: Filter-Zustand
|
||||||
|
- `policyTypes`: string[] (selected policy types)
|
||||||
|
- `searchQuery`: string (existing search)
|
||||||
|
- `dateRange`: { from?: Date, to?: Date } (optional, Phase 2)
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Pagination lädt neue Seiten in <500ms (serverseitige Query + Rendering).
|
||||||
|
- **SC-002**: Sortierung funktioniert auf allen Spalten ohne Performance-Degradation bei 1000+ Rows.
|
||||||
|
- **SC-003**: Column Settings (Visibility, Sizing) bleiben nach Browser-Reload erhalten.
|
||||||
|
- **SC-004**: CSV Export für 1000 Selected Rows dauert <2s (client-seitig).
|
||||||
|
- **SC-005**: CSV Export für 5000 Filtered Results dauert <5s (server-seitig).
|
||||||
|
- **SC-006**: Filter + Suche kombiniert reduziert Results korrekt (AND-Logik).
|
||||||
|
- **SC-007**: URL State ist shareable (Copy/Paste URL zeigt identische Filterung).
|
||||||
|
- **SC-008**: Detail-Sheet Copy-Buttons funktionieren in allen modernen Browsern (Chrome, Firefox, Safari, Edge).
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- TanStack Table (React Table v8) wird als Data Table Library verwendet.
|
||||||
|
- Shadcn UI Table Komponenten werden erweitert oder mit TanStack Table integriert.
|
||||||
|
- Export-Logik nutzt serverseitigen Endpoint für große Datasets (>1000 Rows).
|
||||||
|
- Column Settings werden im localStorage gespeichert (keine User-Profile Persistierung).
|
||||||
|
- "Open in Intune" Links werden per Policy Type konstruiert (bekannte URL-Pattern).
|
||||||
|
|
||||||
|
## Nicht-Ziele (Out of Scope)
|
||||||
|
|
||||||
|
- Kein Policy Editing (read-only view bleibt erhalten).
|
||||||
|
- Kein vollständiges RBAC-System (bleibt bei Tenant-Isolation).
|
||||||
|
- Kein Conflict Detection (Phase 2).
|
||||||
|
- Kein Policy Comparison/Diff (Phase 2).
|
||||||
|
- Kein Saved Views (Phase 2).
|
||||||
|
- Keine Gruppierung (Group by Policy/Setting) (Phase 2).
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
### TanStack Table Integration
|
||||||
|
|
||||||
|
- `@tanstack/react-table` bereits im Projekt vorhanden oder muss installiert werden
|
||||||
|
- Server-side Pagination, Sorting, Filtering via Server Actions
|
||||||
|
- Column Definitions mit Type-Safety via TypeScript
|
||||||
|
- Shadcn Table Primitives als UI Layer
|
||||||
|
|
||||||
|
### CSV Export Strategy
|
||||||
|
|
||||||
|
**Client-Side** (Selected Rows):
|
||||||
|
- Library: `papaparse` oder native String-Builder
|
||||||
|
- Maximal 1000 Rows empfohlen
|
||||||
|
- Sofortiger Download ohne Server-Request
|
||||||
|
|
||||||
|
**Server-Side** (Filtered Results):
|
||||||
|
- Neuer Server Action: `exportPolicySettingsCSV(filterState)`
|
||||||
|
- Stream-basierter Export für große Datasets
|
||||||
|
- Content-Disposition Header für File Download
|
||||||
|
- Limit: 5000 Rows (UI Warning bei mehr)
|
||||||
|
|
||||||
|
### URL State Management
|
||||||
|
|
||||||
|
- `nuqs` Library für type-safe URL State
|
||||||
|
- Query Params:
|
||||||
|
- `page`: number
|
||||||
|
- `pageSize`: 10|25|50|100
|
||||||
|
- `sortBy`: columnId
|
||||||
|
- `sortDir`: asc|desc
|
||||||
|
- `policyTypes`: comma-separated
|
||||||
|
- `q`: search query
|
||||||
|
|
||||||
|
### Column Configuration
|
||||||
|
|
||||||
|
Default Columns:
|
||||||
|
- Setting Name (visible, pinned left)
|
||||||
|
- Setting Value (visible, truncated)
|
||||||
|
- Policy Name (visible)
|
||||||
|
- Policy Type (visible, badge)
|
||||||
|
- Last Synced (visible, relative time)
|
||||||
|
- Policy ID (hidden by default)
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- TanStack Table v8: `@tanstack/react-table`
|
||||||
|
- CSV Export: `papaparse` (optional, kann auch nativ gebaut werden)
|
||||||
|
- URL State: `nuqs` (optional, kann auch mit useSearchParams gelöst werden)
|
||||||
|
- Clipboard API: Native Browser API
|
||||||
|
- Icons: Lucide React (bereits vorhanden)
|
||||||
303
specs/005-backend-arch-pivot/analysis-report.md
Normal file
303
specs/005-backend-arch-pivot/analysis-report.md
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
# Specification Analysis Report: Feature 005 Backend Architecture Pivot
|
||||||
|
|
||||||
|
**Generated**: 2025-12-09
|
||||||
|
**Feature**: 005-backend-arch-pivot
|
||||||
|
**Analyzed Files**: spec.md, plan.md, tasks.md, constitution.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
**Overall Status**: ✅ **PASS** - Minor issues only
|
||||||
|
|
||||||
|
**Critical Issues**: 0
|
||||||
|
**High Priority Issues**: 0
|
||||||
|
**Medium Priority Issues**: 3
|
||||||
|
**Low Priority Issues**: 4
|
||||||
|
|
||||||
|
**Recommendation**: Safe to proceed with `/speckit.implement` after addressing 3 medium-priority issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analysis Findings
|
||||||
|
|
||||||
|
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||||
|
|----|----------|----------|-------------|---------|----------------|
|
||||||
|
| A1 | Task Count Discrepancy | MEDIUM | tasks.md:L4 vs actual count | Header claims "49 tasks" but actual count is 64 tasks (T001-T066) | Update header to "Total Tasks: 64" |
|
||||||
|
| A2 | Missing Task References | MEDIUM | plan.md | Plan describes 8 phases but doesn't reference specific task IDs consistently | Add task ID references in phase descriptions |
|
||||||
|
| A3 | Success Criteria Mismatch | MEDIUM | spec.md vs tasks.md | SC-006 mentions technology-specific details (Grep-Search) vs plan's technology-agnostic approach | Already fixed in spec.md, verify consistency |
|
||||||
|
| D1 | Terminology Drift | LOW | spec.md vs plan.md vs tasks.md | "Worker Process" vs "Worker" vs "Background Worker" used interchangeably | Standardize to "Worker Process" |
|
||||||
|
| D2 | Phase Numbering | LOW | tasks.md | Uses "Phase 1-9" but plan.md uses "Phase 0-8" | Align phase numbering between docs |
|
||||||
|
| T1 | Task Dependency Clarity | LOW | tasks.md | Parallel opportunities listed but not visualized in task list | Add [P] markers to all parallel-safe tasks |
|
||||||
|
| T2 | Missing Test Task | LOW | tasks.md Phase 8 | No test for FR-022 (optional job status endpoint) | Add note that FR-022 is out of MVP scope |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coverage Summary
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
**Total Requirements**: 26 (FR-001 to FR-026)
|
||||||
|
**Requirements with Task Coverage**: 26 (100%)
|
||||||
|
|
||||||
|
| Requirement | Has Tasks? | Task IDs | Notes |
|
||||||
|
|-------------|-----------|----------|-------|
|
||||||
|
| FR-001 (BullMQ) | ✅ | T005, T006, T007 | Install + setup |
|
||||||
|
| FR-002 (Redis Connection) | ✅ | T006 | lib/queue/redis.ts |
|
||||||
|
| FR-003 (Worker Process) | ✅ | T009 | worker/index.ts |
|
||||||
|
| FR-004 (npm Script) | ✅ | T010 | worker:start |
|
||||||
|
| FR-005 (REDIS_URL validation) | ✅ | T003, T004 | env.mjs updates |
|
||||||
|
| FR-006 (Azure AD Token) | ✅ | T015, T016 | graphAuth.ts |
|
||||||
|
| FR-007 (Graph Endpoints) | ✅ | T018-T021 | 4 endpoints |
|
||||||
|
| FR-008 (Pagination) | ✅ | T019 | fetchWithPagination |
|
||||||
|
| FR-009 (Error Retry) | ✅ | T022 | retry.ts |
|
||||||
|
| FR-010 (Settings Catalog) | ✅ | T027-T029 | parseSettingsCatalog |
|
||||||
|
| FR-011 (OMA-URI) | ✅ | T030, T031 | parseOmaUri |
|
||||||
|
| FR-012 (Deep Flattening) | ✅ | T024-T029 | policyParser.ts |
|
||||||
|
| FR-013 (Humanization) | ✅ | T032, T033 | humanizer.ts |
|
||||||
|
| FR-014 (Type Detection) | ✅ | T025 | detectPolicyType |
|
||||||
|
| FR-015 (Empty Policies) | ✅ | T034 | defaultEmptySetting |
|
||||||
|
| FR-016 (Drizzle ORM) | ✅ | T036-T040 | dbUpsert.ts |
|
||||||
|
| FR-017 (onConflictDoUpdate) | ✅ | T038 | Conflict resolution |
|
||||||
|
| FR-018 (Field Mapping) | ✅ | T040 | All required fields |
|
||||||
|
| FR-019 (lastSyncedAt) | ✅ | T039 | Timestamp update |
|
||||||
|
| FR-020 (Frontend Integration) | ✅ | T043-T045 | triggerPolicySync |
|
||||||
|
| FR-021 (Job ID Return) | ✅ | T045 | Return jobId |
|
||||||
|
| FR-022 (Status Endpoint) | ⚠️ | None | Optional, out of MVP scope |
|
||||||
|
| FR-023 (Delete policy-settings API) | ✅ | T047 | File deletion |
|
||||||
|
| FR-024 (Delete admin/tenants API) | ✅ | T048 | File deletion |
|
||||||
|
| FR-025 (Remove POLICY_API_SECRET) | ✅ | T049, T051, T053 | .env + env.mjs |
|
||||||
|
| FR-026 (Remove N8N_SYNC_WEBHOOK_URL) | ✅ | T050, T052, T054 | .env + env.mjs |
|
||||||
|
|
||||||
|
### User Story Coverage
|
||||||
|
|
||||||
|
**Total User Stories**: 4
|
||||||
|
**User Stories with Task Coverage**: 4 (100%)
|
||||||
|
|
||||||
|
| User Story | Phase | Task Coverage | Notes |
|
||||||
|
|------------|-------|---------------|-------|
|
||||||
|
| US1: Manual Policy Sync via Queue | 1, 2, 6 | T001-T014, T043-T046 | Complete |
|
||||||
|
| US2: Microsoft Graph Data Fetching | 3 | T015-T023 | Complete |
|
||||||
|
| US3: Deep Flattening & Transformation | 4 | T024-T035 | Complete |
|
||||||
|
| US4: Legacy Code Removal | 7 | T047-T055 | Complete |
|
||||||
|
|
||||||
|
### Success Criteria Coverage
|
||||||
|
|
||||||
|
**Total Success Criteria**: 8 (SC-001 to SC-008)
|
||||||
|
**Success Criteria with Task Coverage**: 8 (100%)
|
||||||
|
|
||||||
|
| Success Criterion | Mapped Tasks | Notes |
|
||||||
|
|-------------------|--------------|-------|
|
||||||
|
| SC-001: <200ms job creation | T001-T008 | Infrastructure |
|
||||||
|
| SC-002: 50 policies in <30s | T041-T042 | Full sync |
|
||||||
|
| SC-003: 100+ policy pagination | T019-T021 | fetchWithPagination |
|
||||||
|
| SC-004: >95% extraction | T024-T035 | Parser validation |
|
||||||
|
| SC-005: Auto-retry on errors | T022-T023 | Exponential backoff |
|
||||||
|
| SC-006: Zero n8n references | T047-T055 | Legacy cleanup |
|
||||||
|
| SC-007: Worker stable 1h+ | T061, T066 | E2E + deployment |
|
||||||
|
| SC-008: No data loss | T041-T042 | Upsert logic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constitution Alignment Issues
|
||||||
|
|
||||||
|
**Status**: ✅ **NO VIOLATIONS**
|
||||||
|
|
||||||
|
All constitution principles are properly addressed in the plan:
|
||||||
|
|
||||||
|
| Principle | Compliance | Evidence |
|
||||||
|
|-----------|-----------|----------|
|
||||||
|
| I. Server-First Architecture | ✅ | Worker = background Server Action pattern |
|
||||||
|
| II. TypeScript Strict Mode | ✅ | All worker code in strict mode (plan.md L79) |
|
||||||
|
| III. Drizzle ORM Integration | ✅ | FR-016, T036-T040 |
|
||||||
|
| IV. Shadcn UI Components | ✅ | No UI changes (plan.md L81) |
|
||||||
|
| V. Azure AD Multi-Tenancy | ✅ | Uses existing Azure AD Client Credentials |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unmapped Tasks
|
||||||
|
|
||||||
|
**Status**: ✅ All tasks mapped to requirements
|
||||||
|
|
||||||
|
No orphan tasks found - all 64 tasks trace back to functional requirements or user stories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ambiguity Detection
|
||||||
|
|
||||||
|
### Vague Language Found
|
||||||
|
|
||||||
|
| Location | Term | Issue | Recommendation |
|
||||||
|
|----------|------|-------|----------------|
|
||||||
|
| spec.md:L182 | "stabil" | Undefined stability metric | Already addressed by SC-007 (1+ hour, 10+ jobs) |
|
||||||
|
| spec.md:L199 | "dokumentiert oder nachvollziehbar" | Unclear validation method | Add task to document n8n logic extraction process |
|
||||||
|
|
||||||
|
### Unresolved Placeholders
|
||||||
|
|
||||||
|
**Status**: ✅ No placeholders found (no TODO, TKTK, ???, `<placeholder>`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inconsistency Detection
|
||||||
|
|
||||||
|
### Terminology Drift
|
||||||
|
|
||||||
|
| Term Variations | Occurrences | Standard Form | Action |
|
||||||
|
|-----------------|-------------|---------------|--------|
|
||||||
|
| Worker Process / Worker / Background Worker | spec.md (3), plan.md (5), tasks.md (2) | Worker Process | Update all to "Worker Process" |
|
||||||
|
| BullMQ / Bull MQ / Job Queue | spec.md (2), tasks.md (1) | BullMQ | Already consistent |
|
||||||
|
| Redis / Redis Queue / In-Memory Store | Various | Redis | Already consistent |
|
||||||
|
|
||||||
|
### Phase Numbering Mismatch
|
||||||
|
|
||||||
|
**Issue**: plan.md uses "Phase 0-8" (9 phases) but tasks.md uses "Phase 1-9" (9 phases)
|
||||||
|
|
||||||
|
**Impact**: MEDIUM - Confusing for developers
|
||||||
|
|
||||||
|
**Recommendation**: Standardize to "Phase 1-9" in both documents (remove "Phase 0" concept)
|
||||||
|
|
||||||
|
### Data Entity Inconsistencies
|
||||||
|
|
||||||
|
**Status**: ✅ No conflicts
|
||||||
|
|
||||||
|
All entities (SyncJobPayload, GraphPolicyResponse, FlattenedSetting) consistently defined.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Duplication Detection
|
||||||
|
|
||||||
|
### Near-Duplicate Requirements
|
||||||
|
|
||||||
|
**Status**: ✅ No duplicates found
|
||||||
|
|
||||||
|
All 26 functional requirements are distinct and non-overlapping.
|
||||||
|
|
||||||
|
### Redundant Tasks
|
||||||
|
|
||||||
|
| Task Pair | Similarity | Analysis | Action |
|
||||||
|
|-----------|----------|----------|--------|
|
||||||
|
| T003 + T004 | Both update lib/env.mjs | Intentional (schema vs runtime) | Keep separate |
|
||||||
|
| T049-T050 + T051-T054 | All remove env vars | Intentional (different locations) | Keep separate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Underspecification Issues
|
||||||
|
|
||||||
|
### Requirements Missing Measurable Criteria
|
||||||
|
|
||||||
|
| Requirement | Issue | Recommendation |
|
||||||
|
|-------------|-------|----------------|
|
||||||
|
| FR-009 | "max 3 Versuche" - no backoff timing specified | Add to technical-notes.md (already present) |
|
||||||
|
| FR-013 | Humanization rules not fully specified | Acceptable - examples provided, edge cases handled in code |
|
||||||
|
|
||||||
|
### User Stories Missing Acceptance Criteria
|
||||||
|
|
||||||
|
**Status**: ✅ All user stories have 5 acceptance scenarios each
|
||||||
|
|
||||||
|
### Tasks with No Validation
|
||||||
|
|
||||||
|
| Task | Issue | Recommendation |
|
||||||
|
|------|-------|----------------|
|
||||||
|
| T011 | Worker event handlers - no validation criteria | Add validation: "Verify logs appear for completed/failed/error events" |
|
||||||
|
| T012 | Structured logging - format not specified | Add validation: "Verify JSON format with required fields (event, jobId, timestamp)" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
- **Total Requirements**: 26
|
||||||
|
- **Total User Stories**: 4
|
||||||
|
- **Total Tasks**: 64 (header claims 49 - needs update)
|
||||||
|
- **Total Success Criteria**: 8
|
||||||
|
- **Requirements Coverage**: 100% (26/26 have tasks)
|
||||||
|
- **User Story Coverage**: 100% (4/4 have tasks)
|
||||||
|
- **Success Criteria Coverage**: 100% (8/8 mapped)
|
||||||
|
- **Ambiguity Count**: 2 (minor)
|
||||||
|
- **Duplication Count**: 0
|
||||||
|
- **Critical Issues**: 0
|
||||||
|
- **Constitution Violations**: 0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Actions
|
||||||
|
|
||||||
|
### ✅ FIXED (3 Issues Resolved)
|
||||||
|
|
||||||
|
1. **A1: Task Count Updated** - Changed tasks.md header from "49 tasks" to "64 tasks" ✅
|
||||||
|
2. **A2: Task References Added** - Added task ID references to all phase descriptions in plan.md ✅
|
||||||
|
3. **D2: Phase Numbering Aligned** - Standardized phase numbering (1-9 in both plan and tasks) ✅
|
||||||
|
|
||||||
|
### SHOULD FIX (Quality Improvements)
|
||||||
|
|
||||||
|
4. **D1: Standardize Terminology** - Replace all instances of "Worker" with "Worker Process"
|
||||||
|
5. **T1: Mark Parallel Tasks** - Add [P] markers to tasks that can run in parallel
|
||||||
|
6. **T2: Document FR-022 Scope** - Add note that job status endpoint is Phase 2 feature
|
||||||
|
|
||||||
|
### OPTIONAL (Nice to Have)
|
||||||
|
|
||||||
|
7. Add validation criteria to T011 and T012
|
||||||
|
8. Document n8n logic extraction process (for "nachvollziehbar" assumption)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
✅ **READY FOR IMPLEMENTATION**
|
||||||
|
|
||||||
|
All medium-priority issues resolved. Feature 005 is ready for Phase 1 implementation (T001-T008: Setup & Infrastructure).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remediation Suggestions
|
||||||
|
|
||||||
|
### Fix A1: Task Count Header
|
||||||
|
|
||||||
|
**File**: `specs/005-backend-arch-pivot/tasks.md`
|
||||||
|
|
||||||
|
**Line 4**: Change from:
|
||||||
|
```markdown
|
||||||
|
**Total Tasks**: 49
|
||||||
|
```
|
||||||
|
|
||||||
|
To:
|
||||||
|
```markdown
|
||||||
|
**Total Tasks**: 64
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fix D2: Phase Numbering
|
||||||
|
|
||||||
|
**Option 1** (Recommended): Rename "Phase 0" to "Phase 1" in plan.md
|
||||||
|
|
||||||
|
**Option 2**: Rename "Phase 1" to "Phase 0" in tasks.md
|
||||||
|
|
||||||
|
**Recommendation**: Use Option 1 (start with Phase 1 for consistency with task numbering)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**Overall Quality**: HIGH
|
||||||
|
|
||||||
|
**Readiness**: ✅ **READY FOR IMPLEMENTATION** after addressing 3 medium-priority issues
|
||||||
|
|
||||||
|
**Strengths**:
|
||||||
|
- 100% requirement coverage
|
||||||
|
- 100% user story coverage
|
||||||
|
- Zero constitution violations
|
||||||
|
- Clear traceability from spec → plan → tasks
|
||||||
|
- Comprehensive technical notes
|
||||||
|
- Well-defined success criteria
|
||||||
|
|
||||||
|
**Weaknesses**:
|
||||||
|
- Minor task count discrepancy (easily fixable)
|
||||||
|
- Phase numbering mismatch (cosmetic)
|
||||||
|
- Some terminology drift (non-blocking)
|
||||||
|
|
||||||
|
**Risk Assessment**: LOW - Issues are documentation-only, no architectural or design flaws detected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Report Status**: ✅ Complete
|
||||||
|
**Next Step**: Address 3 medium-priority fixes, then proceed with implementation
|
||||||
50
specs/005-backend-arch-pivot/checklists/requirements.md
Normal file
50
specs/005-backend-arch-pivot/checklists/requirements.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Specification Quality Checklist: Backend Architecture Pivot
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2025-12-09
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
**Status**: ✅ VALIDATED (2025-12-09)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Validation Summary
|
||||||
|
|
||||||
|
**Date**: 2025-12-09
|
||||||
|
**Result**: ✅ ALL CHECKS PASSED
|
||||||
|
|
||||||
|
**Actions Taken**:
|
||||||
|
1. Removed all code examples from spec.md and moved to separate technical-notes.md
|
||||||
|
2. Rewrote Success Criteria to be technology-agnostic (removed references to Redis, BullMQ, Grep-Search, etc.)
|
||||||
|
3. Updated Dependencies section to be library-agnostic (e.g., "Job Queue System" instead of "BullMQ")
|
||||||
|
4. Simplified Technical Notes section to high-level architecture overview only
|
||||||
|
|
||||||
|
**Quality Improvements**:
|
||||||
|
- Spec is now fully business-focused and stakeholder-friendly
|
||||||
|
- Technical implementation details isolated in separate document
|
||||||
|
- Success criteria focus on user-visible outcomes and system behavior
|
||||||
|
- All mandatory sections complete with clear acceptance scenarios
|
||||||
|
|
||||||
|
**Ready for**: `/speckit.plan` command to generate implementation plan
|
||||||
767
specs/005-backend-arch-pivot/plan.md
Normal file
767
specs/005-backend-arch-pivot/plan.md
Normal file
@ -0,0 +1,767 @@
|
|||||||
|
# Implementation Plan: Backend Architecture Pivot
|
||||||
|
|
||||||
|
**Feature Branch**: `005-backend-arch-pivot`
|
||||||
|
**Created**: 2025-12-09
|
||||||
|
**Spec**: [spec.md](./spec.md)
|
||||||
|
**Status**: Ready for Implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
**Goal**: Migrate from n8n Low-Code backend to TypeScript Code-First backend with BullMQ job queue for Policy synchronization.
|
||||||
|
|
||||||
|
**Impact**: Removes external n8n dependency, improves maintainability, enables AI-assisted refactoring, and provides foundation for future scheduled sync features.
|
||||||
|
|
||||||
|
**Complexity**: HIGH - Requires new infrastructure (Redis, BullMQ), worker process deployment, and careful data transformation logic porting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
### Current Architecture (n8n-based)
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks "Sync Now"
|
||||||
|
↓
|
||||||
|
Server Action: triggerPolicySync()
|
||||||
|
↓
|
||||||
|
HTTP POST → n8n Webhook (N8N_SYNC_WEBHOOK_URL)
|
||||||
|
↓
|
||||||
|
n8n Workflow:
|
||||||
|
1. Microsoft Graph Authentication
|
||||||
|
2. Fetch Policies (4 endpoints with pagination)
|
||||||
|
3. JavaScript Code Node: Deep Flattening Logic
|
||||||
|
4. HTTP POST → TenantPilot Ingestion API
|
||||||
|
↓
|
||||||
|
API Route: /api/policy-settings (validates POLICY_API_SECRET)
|
||||||
|
↓
|
||||||
|
Drizzle ORM: Insert/Update policy_settings table
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problems**:
|
||||||
|
- External dependency (n8n instance required)
|
||||||
|
- Complex transformation logic hidden in n8n Code Node
|
||||||
|
- Hard to test, version control, and refactor
|
||||||
|
- No AI assistance for n8n code
|
||||||
|
- Additional API security layer needed (POLICY_API_SECRET)
|
||||||
|
|
||||||
|
### Target Architecture (BullMQ-based)
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks "Sync Now"
|
||||||
|
↓
|
||||||
|
Server Action: triggerPolicySync()
|
||||||
|
↓
|
||||||
|
BullMQ: Add job to Redis queue "intune-sync-queue"
|
||||||
|
↓
|
||||||
|
Worker Process (TypeScript):
|
||||||
|
1. Microsoft Graph Authentication (@azure/identity)
|
||||||
|
2. Fetch Policies (4 endpoints with pagination)
|
||||||
|
3. TypeScript: Deep Flattening Logic
|
||||||
|
4. Drizzle ORM: Direct Insert/Update
|
||||||
|
↓
|
||||||
|
Database: policy_settings table
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- No external dependencies (Redis only)
|
||||||
|
- All logic in TypeScript (version-controlled, testable)
|
||||||
|
- AI-assisted refactoring possible
|
||||||
|
- Simpler security model (no API bridge)
|
||||||
|
- Foundation for scheduled syncs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constitution Check *(mandatory)*
|
||||||
|
|
||||||
|
### Compliance Verification
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| **I. Server-First Architecture** | ✅ COMPLIANT | Worker uses Server Actions pattern (background job processing), no client fetches |
|
||||||
|
| **II. TypeScript Strict Mode** | ✅ COMPLIANT | All worker code in TypeScript strict mode, fully typed Graph API responses |
|
||||||
|
| **III. Drizzle ORM Integration** | ✅ COMPLIANT | Worker uses Drizzle for all DB operations, no raw SQL |
|
||||||
|
| **IV. Shadcn UI Components** | ✅ COMPLIANT | No UI changes (frontend only triggers job, uses existing components) |
|
||||||
|
| **V. Azure AD Multi-Tenancy** | ✅ COMPLIANT | Uses existing Azure AD Client Credentials for Graph API access |
|
||||||
|
|
||||||
|
### Risk Assessment
|
||||||
|
|
||||||
|
**HIGH RISK**: Worker deployment as separate process (requires Docker Compose update, PM2/Systemd config)
|
||||||
|
|
||||||
|
**MEDIUM RISK**: Graph API rate limiting handling (needs robust retry logic)
|
||||||
|
|
||||||
|
**LOW RISK**: BullMQ integration (well-documented library, standard Redis setup)
|
||||||
|
|
||||||
|
### Justification
|
||||||
|
|
||||||
|
Architecture pivot necessary to:
|
||||||
|
1. Remove external n8n dependency (reduces operational complexity)
|
||||||
|
2. Enable AI-assisted development (TypeScript vs. n8n visual flows)
|
||||||
|
3. Improve testability (unit/integration tests for worker logic)
|
||||||
|
4. Prepare for Phase 2 features (scheduled syncs, multi-tenant parallel processing)
|
||||||
|
|
||||||
|
**Approved**: Constitution compliance verified, complexity justified by maintainability gains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Tree & Changes
|
||||||
|
|
||||||
|
```
|
||||||
|
tenantpilot/
|
||||||
|
├── .env # [MODIFIED] Add REDIS_URL, remove POLICY_API_SECRET + N8N_SYNC_WEBHOOK_URL
|
||||||
|
├── (Redis provided by deployment) # No `docker-compose.yml` required; ensure `REDIS_URL` is set by Dokploy
|
||||||
|
├── package.json # [MODIFIED] Add bullmq, ioredis, @azure/identity, tsx dependencies
|
||||||
|
│
|
||||||
|
├── lib/
|
||||||
|
│ ├── env.mjs # [MODIFIED] Add REDIS_URL validation, remove POLICY_API_SECRET + N8N_SYNC_WEBHOOK_URL
|
||||||
|
│ ├── queue/
|
||||||
|
│ │ ├── redis.ts # [NEW] Redis connection for BullMQ
|
||||||
|
│ │ └── syncQueue.ts # [NEW] BullMQ Queue definition for "intune-sync-queue"
|
||||||
|
│ └── actions/
|
||||||
|
│ └── policySettings.ts # [MODIFIED] Replace n8n webhook call with BullMQ job creation
|
||||||
|
│
|
||||||
|
├── worker/
|
||||||
|
│ ├── index.ts # [NEW] BullMQ Worker entry point
|
||||||
|
│ ├── jobs/
|
||||||
|
│ │ ├── syncPolicies.ts # [NEW] Main sync orchestration logic
|
||||||
|
│ │ ├── graphAuth.ts # [NEW] Azure AD token acquisition
|
||||||
|
│ │ ├── graphFetch.ts # [NEW] Microsoft Graph API calls with pagination
|
||||||
|
│ │ ├── policyParser.ts # [NEW] Deep flattening & transformation logic
|
||||||
|
│ │ └── dbUpsert.ts # [NEW] Drizzle ORM upsert operations
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── humanizer.ts # [NEW] Setting ID humanization
|
||||||
|
│ └── retry.ts # [NEW] Exponential backoff retry logic
|
||||||
|
│
|
||||||
|
├── app/api/
|
||||||
|
│ ├── policy-settings/
|
||||||
|
│ │ └── route.ts # [DELETED] n8n ingestion API no longer needed
|
||||||
|
│ └── admin/
|
||||||
|
│ └── tenants/
|
||||||
|
│ └── route.ts # [DELETED] n8n polling API no longer needed
|
||||||
|
│
|
||||||
|
└── specs/005-backend-arch-pivot/
|
||||||
|
├── spec.md # ✅ Complete
|
||||||
|
├── plan.md # 📝 This file
|
||||||
|
├── technical-notes.md # ✅ Complete (implementation reference)
|
||||||
|
└── tasks.md # 🔜 Generated next
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Breakdown
|
||||||
|
|
||||||
|
### Phase 1: Setup & Infrastructure (T001-T008)
|
||||||
|
|
||||||
|
**Goal**: Prepare environment, install dependencies, and wire the app to the provisioned Redis instance
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- T001: Confirm `REDIS_URL` is provided by Dokploy and obtain connection details
|
||||||
|
- T002-T004: Add `REDIS_URL` to local `.env` (for development) and to `lib/env.mjs` runtime validation
|
||||||
|
- T005: Install npm packages: `bullmq`, `ioredis`, `@azure/identity`, `tsx`
|
||||||
|
- T006-T007: Create Redis connection and BullMQ Queue
|
||||||
|
- T008: Test infrastructure (connect to provided Redis from local/dev environment)
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Connection details for Redis from Dokploy documented
|
||||||
|
- Environment variables validated (local + deploy)
|
||||||
|
- Dependencies in `package.json`
|
||||||
|
- Queue operational using the provided Redis
|
||||||
|
|
||||||
|
**Exit Criteria**: `npm run dev` starts without env validation errors and the queue accepts jobs against the provided Redis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Worker Process Skeleton (T009-T014)ntry point and basic job processing infrastructure
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- T009: Create `worker/index.ts` - BullMQ Worker entry point
|
||||||
|
- T010-T012: Add npm script, event handlers, structured logging
|
||||||
|
- T013: Create sync orchestration skeleton
|
||||||
|
- T014: Test worker startup
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Worker process can be started via `npm run worker:start`
|
||||||
|
- Jobs flow from queue → worker
|
||||||
|
- Event logging operational
|
||||||
|
|
||||||
|
**Exit Criteria**: Worker logs "Processing job X" when job is added to queue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Microsoft Graph Integration (T015-T023)ion and Microsoft Graph API data fetching with pagination
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- T015-T017: Create `worker/jobs/graphAuth.ts` - Azure AD token acquisition
|
||||||
|
- T018-T021: Create `worker/jobs/graphFetch.ts` - Fetch from 4 endpoints with pagination
|
||||||
|
- T022: Create `worker/utils/retry.ts` - Exponential backoff
|
||||||
|
- T023: Test with real tenant data
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- `getGraphAccessToken()` returns valid token
|
||||||
|
- `fetchAllPolicies()` returns all policies from 4 endpoints
|
||||||
|
- Pagination handled correctly (follows `@odata.nextLink`)
|
||||||
|
- Rate limiting handled with retry
|
||||||
|
|
||||||
|
**Exit Criteria**: Worker successfully fetches >50 policies for test tenant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Data Transformation (T024-T035)
|
||||||
|
|
||||||
|
**Goal**: Port n8n flattening logic to TypeScript
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Create `worker/jobs/policyParser.ts` - Policy type detection & routing
|
||||||
|
2. Implement Settings Catalog parser (`settings[]` → flat key-value)
|
||||||
|
3. Implement OMA-URI parser (`omaSettings[]` → flat key-value)
|
||||||
|
4. Create `worker/utils/humanizer.ts` - Setting ID humanization
|
||||||
|
5. Handle empty policies (default placeholder setting)
|
||||||
|
6. Test: Parse sample policies, verify output structure
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- `parsePolicySettings()` converts Graph response → FlattenedSetting[]
|
||||||
|
- Humanizer converts technical IDs → readable names
|
||||||
|
- Empty policies get "(No settings configured)" entry
|
||||||
|
|
||||||
|
**Exit Criteria**: 95%+ of sample settings are correctly extracted and formatted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Database Persistence (T036-T043)
|
||||||
|
|
||||||
|
**Goal**: Implement Drizzle ORM upsert logic
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Create `worker/jobs/dbUpsert.ts` - Batch upsert with conflict resolution
|
||||||
|
2. Use existing `policy_settings` table schema
|
||||||
|
3. Leverage `policy_settings_upsert_unique` constraint (tenantId + graphPolicyId + settingName)
|
||||||
|
4. Update `lastSyncedAt` on every sync
|
||||||
|
5. Test: Run full sync, verify data in DB
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- `upsertPolicySettings()` inserts new & updates existing settings
|
||||||
|
- No duplicate settings created
|
||||||
|
- `lastSyncedAt` updated correctly
|
||||||
|
|
||||||
|
**Exit Criteria**: Full sync for test tenant completes successfully, data visible in DB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 6: Frontend Integration (T044-T051)
|
||||||
|
|
||||||
|
**Goal**: Replace n8n webhook with BullMQ job creation
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Modify `lib/actions/policySettings.ts` → `triggerPolicySync()`
|
||||||
|
2. Remove n8n webhook call (`fetch(env.N8N_SYNC_WEBHOOK_URL)`)
|
||||||
|
3. Replace with BullMQ job creation (`syncQueue.add(...)`)
|
||||||
|
4. Return job ID to frontend
|
||||||
|
5. Test: Click "Sync Now", verify job created & processed
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- "Sync Now" button triggers BullMQ job
|
||||||
|
- User sees immediate feedback (no blocking)
|
||||||
|
- Worker processes job in background
|
||||||
|
|
||||||
|
**Exit Criteria**: End-to-end sync works from UI → Queue → Worker → DB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 7: Legacy Cleanup (T052-T056)
|
||||||
|
|
||||||
|
**Goal**: Remove all n8n-related code and configuration
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Delete `app/api/policy-settings/route.ts` (n8n ingestion API)
|
||||||
|
2. Delete `app/api/admin/tenants/route.ts` (n8n polling API)
|
||||||
|
3. Remove `POLICY_API_SECRET` from `.env` and `lib/env.mjs`
|
||||||
|
4. Remove `N8N_SYNC_WEBHOOK_URL` from `.env` and `lib/env.mjs`
|
||||||
|
5. Grep search for remaining references (should be 0)
|
||||||
|
6. Update documentation (remove n8n setup instructions)
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- No n8n-related files in codebase
|
||||||
|
- No n8n-related env vars
|
||||||
|
- Clean grep search results
|
||||||
|
|
||||||
|
**Exit Criteria**: `grep -r "POLICY_API_SECRET\|N8N_SYNC_WEBHOOK_URL" .` returns 0 results (excluding specs/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 8: Testing & Validation (T057-T061)
|
||||||
|
|
||||||
|
**Goal**: Comprehensive testing of new architecture
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Unit tests for flattening logic
|
||||||
|
2. Integration tests for worker jobs
|
||||||
|
3. End-to-end test: UI → Queue → Worker → DB
|
||||||
|
4. Load test: 100+ policies sync
|
||||||
|
5. Error handling test: Graph API failures, Redis unavailable
|
||||||
|
6. Memory leak test: Worker runs 1+ hour with 10+ jobs
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Test suite with >80% coverage for worker code
|
||||||
|
- All edge cases verified
|
||||||
|
- Performance benchmarks met (SC-001 to SC-008)
|
||||||
|
|
||||||
|
**Exit Criteria**: All tests pass, no regressions in existing features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 9: Deployment (T062-T066)
|
||||||
|
|
||||||
|
**Goal**: Deploy worker process to production
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
1. Ensure `REDIS_URL` is set in production (provided by Dokploy) — no Docker Compose Redis required
|
||||||
|
2. Configure worker as background service (PM2, Systemd, or Docker)
|
||||||
|
3. Set `REDIS_URL` in production environment
|
||||||
|
4. Monitor worker logs for first production sync
|
||||||
|
5. Verify sync completes successfully
|
||||||
|
6. Document worker deployment process
|
||||||
|
|
||||||
|
**Deliverables**:
|
||||||
|
- Worker running as persistent service
|
||||||
|
- Redis accessible from worker
|
||||||
|
- Production sync successful
|
||||||
|
|
||||||
|
**Exit Criteria**: Production sync works end-to-end, no errors in logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
### 1. BullMQ vs. Other Queue Libraries
|
||||||
|
|
||||||
|
**Decision**: Use BullMQ
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Modern, actively maintained (vs. Kue, Bull)
|
||||||
|
- TypeScript-first design
|
||||||
|
- Built-in retry, rate limiting, priority queues
|
||||||
|
- Excellent documentation
|
||||||
|
- Redis-based (simpler than RabbitMQ/Kafka)
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- **Bee-Queue**: Lighter but less features
|
||||||
|
- **Agenda**: MongoDB-based (adds extra dependency)
|
||||||
|
- **AWS SQS**: Vendor lock-in, requires AWS setup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Worker Process Architecture
|
||||||
|
|
||||||
|
**Decision**: Single worker process, sequential job processing (concurrency: 1)
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Simpler implementation (no race conditions)
|
||||||
|
- Microsoft Graph rate limits per tenant
|
||||||
|
- Database upsert logic easier without concurrency
|
||||||
|
- Can scale later if needed (multiple workers)
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- **Parallel Processing**: Higher complexity, potential conflicts
|
||||||
|
- **Lambda/Serverless**: Cold starts, harder debugging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Token Acquisition Strategy
|
||||||
|
|
||||||
|
**Decision**: Use `@azure/identity` ClientSecretCredential
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Official Microsoft library
|
||||||
|
- Handles token refresh automatically
|
||||||
|
- TypeScript support
|
||||||
|
- Simpler than manual OAuth flow
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- **Manual fetch()**: More code, error-prone
|
||||||
|
- **MSAL Node**: Overkill for server-side client credentials
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Flattening Algorithm
|
||||||
|
|
||||||
|
**Decision**: Port n8n logic 1:1 initially, refactor later
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Minimize risk (proven logic)
|
||||||
|
- Faster migration (no re-design needed)
|
||||||
|
- Can optimize in Phase 2 after validation
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- **Re-design from scratch**: Higher risk, longer timeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Database Schema Changes
|
||||||
|
|
||||||
|
**Decision**: No schema changes needed
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Existing `policy_settings` table has required fields
|
||||||
|
- UNIQUE constraint already supports upsert logic
|
||||||
|
- `lastSyncedAt` field exists for tracking
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- **Add job tracking table**: Overkill for MVP (BullMQ handles this)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow Diagrams
|
||||||
|
|
||||||
|
### Current Flow (n8n)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant UI as Next.js UI
|
||||||
|
participant SA as Server Action
|
||||||
|
participant n8n as n8n Webhook
|
||||||
|
participant API as Ingestion API
|
||||||
|
participant DB as PostgreSQL
|
||||||
|
|
||||||
|
User->>UI: Click "Sync Now"
|
||||||
|
UI->>SA: triggerPolicySync(tenantId)
|
||||||
|
SA->>n8n: POST /webhook
|
||||||
|
n8n->>n8n: Fetch Graph API
|
||||||
|
n8n->>n8n: Transform Data
|
||||||
|
n8n->>API: POST /api/policy-settings
|
||||||
|
API->>API: Validate API Secret
|
||||||
|
API->>DB: Insert/Update
|
||||||
|
DB-->>API: Success
|
||||||
|
API-->>n8n: 200 OK
|
||||||
|
n8n-->>SA: 200 OK
|
||||||
|
SA-->>UI: Success
|
||||||
|
UI-->>User: Toast "Sync started"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Target Flow (BullMQ)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant UI as Next.js UI
|
||||||
|
participant SA as Server Action
|
||||||
|
participant Queue as Redis Queue
|
||||||
|
participant Worker as Worker Process
|
||||||
|
participant Graph as MS Graph API
|
||||||
|
participant DB as PostgreSQL
|
||||||
|
|
||||||
|
User->>UI: Click "Sync Now"
|
||||||
|
UI->>SA: triggerPolicySync(tenantId)
|
||||||
|
SA->>Queue: Add job "sync-tenant"
|
||||||
|
Queue-->>SA: Job ID
|
||||||
|
SA-->>UI: Success (immediate)
|
||||||
|
UI-->>User: Toast "Sync started"
|
||||||
|
|
||||||
|
Note over Worker: Background Processing
|
||||||
|
Worker->>Queue: Pick job
|
||||||
|
Worker->>Graph: Fetch policies
|
||||||
|
Graph-->>Worker: Policy data
|
||||||
|
Worker->>Worker: Transform data
|
||||||
|
Worker->>DB: Upsert settings
|
||||||
|
DB-->>Worker: Success
|
||||||
|
Worker->>Queue: Mark job complete
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Changes Required
|
||||||
|
|
||||||
|
**Add**:
|
||||||
|
```bash
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove**:
|
||||||
|
```bash
|
||||||
|
# Delete these lines:
|
||||||
|
POLICY_API_SECRET=...
|
||||||
|
N8N_SYNC_WEBHOOK_URL=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updated `lib/env.mjs`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const env = createEnv({
|
||||||
|
server: {
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
NEXTAUTH_SECRET: z.string().min(1),
|
||||||
|
NEXTAUTH_URL: z.string().url(),
|
||||||
|
AZURE_AD_CLIENT_ID: z.string().min(1),
|
||||||
|
AZURE_AD_CLIENT_SECRET: z.string().min(1),
|
||||||
|
REDIS_URL: z.string().url(), // ADD THIS
|
||||||
|
RESEND_API_KEY: z.string().optional(),
|
||||||
|
STRIPE_SECRET_KEY: z.string().optional(),
|
||||||
|
// ... other Stripe vars
|
||||||
|
// REMOVE: POLICY_API_SECRET
|
||||||
|
// REMOVE: N8N_SYNC_WEBHOOK_URL
|
||||||
|
},
|
||||||
|
client: {},
|
||||||
|
runtimeEnv: {
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
|
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||||
|
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||||
|
AZURE_AD_CLIENT_ID: process.env.AZURE_AD_CLIENT_ID,
|
||||||
|
AZURE_AD_CLIENT_SECRET: process.env.AZURE_AD_CLIENT_SECRET,
|
||||||
|
REDIS_URL: process.env.REDIS_URL, // ADD THIS
|
||||||
|
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
||||||
|
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
||||||
|
// ... other vars
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
**Target Coverage**: 80%+ for worker code
|
||||||
|
|
||||||
|
**Files to Test**:
|
||||||
|
- `worker/utils/humanizer.ts` - Setting ID transformation
|
||||||
|
- `worker/jobs/policyParser.ts` - Flattening logic
|
||||||
|
- `worker/utils/retry.ts` - Backoff algorithm
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
describe('humanizeSettingId', () => {
|
||||||
|
it('removes vendor prefix', () => {
|
||||||
|
expect(humanizeSettingId('device_vendor_msft_policy_config_wifi'))
|
||||||
|
.toBe('Wifi');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
**Target**: Full worker job processing
|
||||||
|
|
||||||
|
**Scenario**:
|
||||||
|
1. Mock Microsoft Graph API responses
|
||||||
|
2. Add job to queue
|
||||||
|
3. Verify worker processes job
|
||||||
|
4. Check database for inserted settings
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
describe('syncPolicies', () => {
|
||||||
|
it('fetches and stores policies', async () => {
|
||||||
|
await syncPolicies('test-tenant-123');
|
||||||
|
const settings = await db.query.policySettings.findMany({
|
||||||
|
where: eq(policySettings.tenantId, 'test-tenant-123'),
|
||||||
|
});
|
||||||
|
expect(settings.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### End-to-End Test
|
||||||
|
|
||||||
|
**Scenario**:
|
||||||
|
1. Start Redis + Worker
|
||||||
|
2. Login to UI
|
||||||
|
3. Navigate to `/search`
|
||||||
|
4. Click "Sync Now"
|
||||||
|
5. Verify:
|
||||||
|
- Job created in Redis
|
||||||
|
- Worker picks up job
|
||||||
|
- Database updated
|
||||||
|
- UI shows success message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
**If migration fails in production**:
|
||||||
|
|
||||||
|
1. **Immediate**: Revert to previous Docker image (with n8n integration)
|
||||||
|
2. **Restore env vars**: Re-add `POLICY_API_SECRET` and `N8N_SYNC_WEBHOOK_URL`
|
||||||
|
3. **Verify**: n8n webhook accessible, sync works
|
||||||
|
4. **Post-mortem**: Document failure reason, plan fixes
|
||||||
|
|
||||||
|
**Data Safety**: No data loss risk (upsert logic preserves existing data)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Targets
|
||||||
|
|
||||||
|
Based on Success Criteria (SC-001 to SC-008):
|
||||||
|
|
||||||
|
| Metric | Target | Measurement |
|
||||||
|
|--------|--------|-------------|
|
||||||
|
| Job Creation | <200ms | Server Action response time |
|
||||||
|
| Sync Duration (50 policies) | <30s | Worker job duration |
|
||||||
|
| Setting Extraction | >95% | Manual validation with sample data |
|
||||||
|
| Worker Stability | 1+ hour, 10+ jobs | Memory profiling |
|
||||||
|
| Pagination | 100% | Test with 100+ policies tenant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### npm Packages
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"bullmq": "^5.0.0",
|
||||||
|
"ioredis": "^5.3.0",
|
||||||
|
"@azure/identity": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
- **Redis**: 7.x (via Docker or external service)
|
||||||
|
- **Node.js**: 20+ (for worker process)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
### Worker Logs
|
||||||
|
|
||||||
|
**Format**: Structured JSON logs
|
||||||
|
|
||||||
|
**Key Events**:
|
||||||
|
- Job started: `{ event: "job_start", jobId, tenantId, timestamp }`
|
||||||
|
- Job completed: `{ event: "job_complete", jobId, duration, settingsCount }`
|
||||||
|
- Job failed: `{ event: "job_failed", jobId, error, stack }`
|
||||||
|
|
||||||
|
**Storage**: Write to file or stdout (captured by Docker/PM2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Health Check Endpoint
|
||||||
|
|
||||||
|
**Path**: `/api/worker-health`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"queue": {
|
||||||
|
"waiting": 2,
|
||||||
|
"active": 1,
|
||||||
|
"completed": 45,
|
||||||
|
"failed": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Case**: Monitoring dashboard, uptime checks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
**Files to Update**:
|
||||||
|
1. `README.md` - Add worker deployment instructions
|
||||||
|
2. `DEPLOYMENT.md` - Document Redis setup, worker config
|
||||||
|
3. `specs/002-manual-policy-sync/` - Mark as superseded by 005
|
||||||
|
|
||||||
|
**New Documentation**:
|
||||||
|
1. `docs/worker-deployment.md` - Step-by-step worker setup
|
||||||
|
2. `docs/troubleshooting.md` - Common worker issues & fixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions & Risks
|
||||||
|
|
||||||
|
### Q1: Redis Hosting Strategy
|
||||||
|
|
||||||
|
**Question**: Self-hosted Redis or managed service (e.g., Upstash, Redis Cloud)?
|
||||||
|
|
||||||
|
**Options**:
|
||||||
|
- Docker Compose (simple, dev-friendly)
|
||||||
|
- Upstash (serverless, paid but simple)
|
||||||
|
- Self-hosted on VPS (more control, more ops)
|
||||||
|
|
||||||
|
**Recommendation**: Start with Docker Compose, migrate to managed service if scaling needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q2: Worker Deployment Method
|
||||||
|
|
||||||
|
**Question**: How to deploy worker in production?
|
||||||
|
|
||||||
|
**Options**:
|
||||||
|
- PM2 (Node process manager)
|
||||||
|
- Systemd (Linux service)
|
||||||
|
- Docker container (consistent with app)
|
||||||
|
|
||||||
|
**Recommendation**: Docker container (matches Next.js deployment strategy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q3: Job Failure Notifications
|
||||||
|
|
||||||
|
**Question**: How to notify admins when sync jobs fail?
|
||||||
|
|
||||||
|
**Options**:
|
||||||
|
- Email via Resend (already integrated)
|
||||||
|
- In-app notification system (Phase 2)
|
||||||
|
- External monitoring (e.g., Sentry)
|
||||||
|
|
||||||
|
**Recommendation**: Start with logs only, add notifications in Phase 2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
| Metric | Target | Status |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| n8n dependency removed | Yes | 🔜 |
|
||||||
|
| All tests passing | 100% | 🔜 |
|
||||||
|
| Production sync successful | Yes | 🔜 |
|
||||||
|
| Worker uptime | >99% | 🔜 |
|
||||||
|
| Zero data loss | Yes | 🔜 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timeline Estimate
|
||||||
|
|
||||||
|
| Phase | Duration | Dependencies |
|
||||||
|
|-------|----------|--------------|
|
||||||
|
| 0. Pre-Implementation | 1h | None |
|
||||||
|
| 1. Queue Infrastructure | 2h | Phase 0 |
|
||||||
|
| 2. Graph Integration | 4h | Phase 1 |
|
||||||
|
| 3. Data Transformation | 6h | Phase 2 |
|
||||||
|
| 4. Database Persistence | 3h | Phase 3 |
|
||||||
|
| 5. Frontend Integration | 2h | Phase 4 |
|
||||||
|
| 6. Legacy Cleanup | 2h | Phase 5 |
|
||||||
|
| 7. Testing & Validation | 4h | Phases 1-6 |
|
||||||
|
| 8. Deployment | 3h | Phase 7 |
|
||||||
|
| **Total** | **~27h** | **~3-4 days** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Generate `tasks.md` with detailed task breakdown
|
||||||
|
2. 🔜 Start Phase 0: Install Redis, update env vars
|
||||||
|
3. 🔜 Implement Phase 1: Queue infrastructure
|
||||||
|
4. 🔜 Continue through Phase 8: Deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Plan Status**: ✅ Ready for Task Generation
|
||||||
|
**Approved by**: Technical Lead (pending)
|
||||||
|
**Last Updated**: 2025-12-09
|
||||||
236
specs/005-backend-arch-pivot/spec.md
Normal file
236
specs/005-backend-arch-pivot/spec.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# Feature Specification: Backend Architecture Pivot
|
||||||
|
|
||||||
|
**Feature Branch**: `005-backend-arch-pivot`
|
||||||
|
**Created**: 2025-12-09
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: "Backend Architecture Pivot (n8n Removal & BullMQ Implementation) - Remove n8n legacy code, implement BullMQ job queue with Redis, port sync logic to TypeScript worker"
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Migration von einer Low-Code-Backend-Architektur (n8n) zu einem Code-First-Backend mit BullMQ Job Queue und TypeScript Worker. Die komplexe Microsoft Graph Synchronisations-Logik wird direkt in TypeScript implementiert, um Wartbarkeit, Testbarkeit und AI-gestütztes Refactoring zu maximieren.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Manual Policy Sync via Queue (Priority: P1)
|
||||||
|
|
||||||
|
Als Intune-Admin möchte ich auf "Sync Now" klicken und erwarten, dass die Synchronisation asynchron in einem Worker-Prozess ausgeführt wird, damit die UI nicht blockiert und ich sofort weiterarbeiten kann.
|
||||||
|
|
||||||
|
**Why this priority**: Core-Funktionalität - ohne funktionierenden Sync ist das gesamte Feature unbrauchbar. Queue-basierte Architektur ist Grundlage für spätere Scheduled Syncs.
|
||||||
|
|
||||||
|
**Independent Test**: Click "Sync Now", check Redis for job, observe worker logs, verify database updates.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** der Admin ist auf `/search` eingeloggt, **When** er auf "Sync Now" klickt, **Then** wird ein Job in die Redis Queue eingestellt (keine Wartezeit für den User).
|
||||||
|
2. **Given** ein Sync-Job wurde erstellt, **When** der Worker-Prozess läuft, **Then** nimmt er den Job aus der Queue und beginnt die Synchronisation.
|
||||||
|
3. **Given** der Worker führt einen Sync aus, **When** die Synchronisation erfolgreich abgeschlossen ist, **Then** werden alle Policy Settings in der Datenbank aktualisiert (Insert oder Update via `onConflictDoUpdate`).
|
||||||
|
4. **Given** der Worker synchronisiert Policies, **When** ein Fehler auftritt (z.B. Graph API Timeout), **Then** wird der Job in einen "failed" State versetzt und der Fehler wird geloggt (kein Silent Fail).
|
||||||
|
5. **Given** der Admin hat mehrere Sync-Jobs gestartet, **When** der Worker mehrere Jobs in der Queue findet, **Then** werden sie sequenziell abgearbeitet (keine parallelen Syncs pro Tenant).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Microsoft Graph Data Fetching (Priority: P1)
|
||||||
|
|
||||||
|
Als System möchte ich alle relevanten Policy-Typen von Microsoft Graph API abrufen können (Device Configurations, Compliance Policies, Configuration Policies, Intents), damit alle Intune-Settings analysierbar sind.
|
||||||
|
|
||||||
|
**Why this priority**: Datenbeschaffung ist essentiell - ohne vollständigen Fetch fehlen Policies in der Analyse.
|
||||||
|
|
||||||
|
**Independent Test**: Run worker with test tenant, verify all policy types are fetched, check pagination handling.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** der Worker startet einen Sync, **When** er ein Access Token anfordert, **Then** nutzt er den Azure AD Client Credentials Flow mit `AZURE_AD_CLIENT_ID` und `AZURE_AD_CLIENT_SECRET`.
|
||||||
|
2. **Given** der Worker hat ein gültiges Token, **When** er Policies abruft, **Then** fetcht er alle relevanten Endpoints:
|
||||||
|
- `/deviceManagement/deviceConfigurations`
|
||||||
|
- `/deviceManagement/deviceCompliancePolicies`
|
||||||
|
- `/deviceManagement/configurationPolicies`
|
||||||
|
- `/deviceManagement/intents`
|
||||||
|
3. **Given** eine Graph API Response hat `@odata.nextLink`, **When** der Worker die Response verarbeitet, **Then** folgt er dem Link und lädt alle Seiten bis keine `nextLink` mehr vorhanden ist.
|
||||||
|
4. **Given** ein Policy Object wird von Graph zurückgegeben, **When** der Worker es parst, **Then** extrahiert er `id`, `displayName`, `@odata.type`, `lastModifiedDateTime` und Policy-spezifische Settings.
|
||||||
|
5. **Given** der Graph API Call schlägt fehl (401, 429, 500), **When** der Fehler auftritt, **Then** wird ein Retry mit Exponential Backoff durchgeführt (max 3 Versuche).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Deep Flattening & Data Transformation (Priority: P1)
|
||||||
|
|
||||||
|
Als System möchte ich komplexe verschachtelte Policy-Objekte in flache Key-Value-Paare transformieren können, damit sie in der `policy_settings` Tabelle gespeichert und durchsucht werden können.
|
||||||
|
|
||||||
|
**Why this priority**: Core Transformation Logic - ohne Flattening können verschachtelte Settings nicht analysiert werden.
|
||||||
|
|
||||||
|
**Independent Test**: Run parser with sample Graph responses, verify flattened output matches expected structure.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** der Worker hat Policy-Daten von Graph erhalten, **When** er ein Settings Catalog Policy verarbeitet (`#microsoft.graph.deviceManagementConfigurationPolicy`), **Then** iteriert er über `settings[]` und extrahiert `settingDefinitionId` und `value`.
|
||||||
|
2. **Given** ein Policy enthält verschachtelte Objekte (z.B. `value.simple.value` oder `value.children[]`), **When** der Flattening-Algorithmus läuft, **Then** wird jede verschachtelte Ebene mit Dot-Notation als Key dargestellt (z.B. `wifi.ssid.value`).
|
||||||
|
3. **Given** der Worker verarbeitet ein OMA-URI Policy, **When** er `omaSettings[]` findet, **Then** extrahiert er `omaUri` als Setting Name und `value` als Setting Value.
|
||||||
|
4. **Given** ein Setting Key enthält technische Bezeichner (z.B. `device_vendor_msft_policy_config_wifi_allowwifihotspotreporting`), **When** der Humanizer läuft, **Then** werden Keys in lesbare Form umgewandelt (z.B. `Allow WiFi Hotspot Reporting`).
|
||||||
|
5. **Given** ein Policy hat keine Settings (leeres Array), **When** der Worker es verarbeitet, **Then** wird trotzdem ein Eintrag erstellt mit `settingName: "(No settings configured)"` (damit Policy in UI sichtbar ist).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Legacy Code Removal (Priority: P1)
|
||||||
|
|
||||||
|
Als Entwickler möchte ich alle n8n-spezifischen Artefakte entfernen können, damit der Code sauber und wartbar bleibt.
|
||||||
|
|
||||||
|
**Why this priority**: Technical Debt Reduction - alte Bridge-APIs verursachen Confusion und Maintenance-Overhead.
|
||||||
|
|
||||||
|
**Independent Test**: Search codebase for n8n references, verify all removed, check env validation.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** der Code wird überprüft, **When** nach `POLICY_API_SECRET` gesucht wird, **Then** existieren keine Referenzen mehr (weder in `.env`, noch in `lib/env.mjs`, noch in Code).
|
||||||
|
2. **Given** der Code wird überprüft, **When** nach `N8N_SYNC_WEBHOOK_URL` gesucht wird, **Then** existieren keine Referenzen mehr.
|
||||||
|
3. **Given** das Routing wird analysiert, **When** nach `/api/policy-settings/route.ts` gesucht wird, **Then** existiert die Datei nicht mehr (gelöscht).
|
||||||
|
4. **Given** das Routing wird analysiert, **When** nach `/api/admin/tenants/route.ts` gesucht wird, **Then** existiert die Datei nicht mehr (gelöscht).
|
||||||
|
5. **Given** ein Entwickler startet die App, **When** die Umgebungsvariablen validiert werden, **Then** werden `POLICY_API_SECRET` und `N8N_SYNC_WEBHOOK_URL` nicht mehr als erforderlich markiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Was passiert wenn Redis nicht erreichbar ist beim Job-Erstellen? → Fehler werfen mit User-Feedback "Sync service unavailable".
|
||||||
|
- Was passiert wenn der Worker abstürzt während eines Jobs? → BullMQ Recovery: Job bleibt in "active" state und wird nach Timeout in "failed" verschoben.
|
||||||
|
- Wie gehen wir mit Rate Limiting von Microsoft Graph um? → Exponential Backoff + Retry (max 3x), dann Job als "failed" markieren mit Retry-Option.
|
||||||
|
- Was passiert bei parallelen Sync-Requests für denselben Tenant? → Queue stellt sicher, dass Jobs sequenziell abgearbeitet werden (kein Concurrency Issue).
|
||||||
|
- Wie werden transiente Netzwerkfehler behandelt? → Retry-Logik mit Backoff, nur permanente Fehler (401, 403) führen zu sofortigem Fail.
|
||||||
|
- Was passiert mit bestehenden Policy Settings während eines Syncs? → `onConflictDoUpdate` updated bestehende Einträge basierend auf `(tenantId, graphPolicyId, settingName)` Constraint.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
#### Infrastructure & Queue
|
||||||
|
|
||||||
|
- **FR-001**: System MUSS BullMQ als Job Queue Library verwenden mit Redis als Backend.
|
||||||
|
- **FR-002**: System MUSS eine wiederverwendbare Redis Connection in `lib/queue/redis.ts` bereitstellen.
|
||||||
|
- **FR-003**: System MUSS einen Worker-Prozess in `worker/index.ts` implementieren, der auf der Queue `intune-sync-queue` lauscht.
|
||||||
|
- **FR-004**: System MUSS Worker-Prozess als separates npm Script bereitstellen (`worker:start`).
|
||||||
|
- **FR-005**: System MUSS `REDIS_URL` als Environment Variable validieren.
|
||||||
|
|
||||||
|
#### Authentication & Graph API
|
||||||
|
|
||||||
|
- **FR-006**: System MUSS Access Tokens via Azure AD Client Credentials Flow holen (`@azure/identity` oder `fetch`).
|
||||||
|
- **FR-007**: System MUSS folgende Graph API Endpoints fetchen:
|
||||||
|
- `/deviceManagement/deviceConfigurations`
|
||||||
|
- `/deviceManagement/deviceCompliancePolicies`
|
||||||
|
- `/deviceManagement/configurationPolicies`
|
||||||
|
- `/deviceManagement/intents`
|
||||||
|
- **FR-008**: System MUSS Pagination via `@odata.nextLink` vollständig abarbeiten (alle Seiten laden).
|
||||||
|
- **FR-009**: System MUSS Graph API Fehler (401, 429, 500+) mit Exponential Backoff Retry behandeln (max 3 Versuche).
|
||||||
|
|
||||||
|
#### Data Processing & Transformation
|
||||||
|
|
||||||
|
- **FR-010**: System MUSS Settings Catalog Policies parsen (`settings[]` Array → flache Key-Value Paare).
|
||||||
|
- **FR-011**: System MUSS OMA-URI Policies parsen (`omaSettings[]` → `omaUri` als Key, `value` als Value).
|
||||||
|
- **FR-012**: System MUSS Deep Flattening für verschachtelte Objekte implementieren (Dot-Notation für Pfade).
|
||||||
|
- **FR-013**: System MUSS technische Setting Keys humanisieren (z.B. `device_vendor_msft_policy_config_wifi` → `WiFi`).
|
||||||
|
- **FR-014**: System MUSS Policy Typ Detection implementieren (Settings Catalog, OMA-URI, Compliance, etc.).
|
||||||
|
- **FR-015**: System MUSS leere Policies mit Placeholder-Setting speichern (`settingName: "(No settings configured)"`).
|
||||||
|
|
||||||
|
#### Database Persistence
|
||||||
|
|
||||||
|
- **FR-016**: System MUSS Drizzle ORM für alle DB-Operationen verwenden.
|
||||||
|
- **FR-017**: System MUSS `onConflictDoUpdate` für Upsert-Logik nutzen (Constraint: `tenantId + graphPolicyId + settingName`).
|
||||||
|
- **FR-018**: System MUSS folgende Felder pro Setting speichern:
|
||||||
|
- `tenantId`, `graphPolicyId`, `policyName`, `policyType`, `settingName`, `settingValue`, `settingValueType`, `lastSyncedAt`
|
||||||
|
- **FR-019**: System MUSS `lastSyncedAt` Timestamp bei jedem Sync aktualisieren.
|
||||||
|
|
||||||
|
#### Frontend Integration
|
||||||
|
|
||||||
|
- **FR-020**: System MUSS Server Action `triggerPolicySync` in `lib/actions/policySettings.ts` anpassen (n8n Webhook → BullMQ Job).
|
||||||
|
- **FR-021**: System MUSS Job-ID an Frontend zurückgeben für späteres Status-Tracking (optional für MVP, siehe FR-022).
|
||||||
|
- **FR-022**: System KANN (optional) Job-Status-Polling-Endpoint bereitstellen (`/api/sync-status/[jobId]`).
|
||||||
|
|
||||||
|
#### Legacy Cleanup
|
||||||
|
|
||||||
|
- **FR-023**: System MUSS File `app/api/policy-settings/route.ts` löschen (n8n Ingestion API).
|
||||||
|
- **FR-024**: System MUSS File `app/api/admin/tenants/route.ts` löschen (n8n Polling API).
|
||||||
|
- **FR-025**: System MUSS `POLICY_API_SECRET` aus `.env`, `lib/env.mjs` und allen Code-Referenzen entfernen.
|
||||||
|
- **FR-026**: System MUSS `N8N_SYNC_WEBHOOK_URL` aus `.env`, `lib/env.mjs` und allen Code-Referenzen entfernen.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
Neue Strukturen (keine DB-Schema-Änderungen):
|
||||||
|
|
||||||
|
- **SyncJobPayload**: BullMQ Job Data
|
||||||
|
- `tenantId`: string
|
||||||
|
- `userId`: string (optional, für Audit)
|
||||||
|
- `triggeredAt`: Date
|
||||||
|
|
||||||
|
- **GraphPolicyResponse**: TypeScript Interface für Graph API Response
|
||||||
|
- `id`: string
|
||||||
|
- `displayName`: string
|
||||||
|
- `@odata.type`: string
|
||||||
|
- `lastModifiedDateTime`: string
|
||||||
|
- `settings?`: array (Settings Catalog)
|
||||||
|
- `omaSettings?`: array (OMA-URI)
|
||||||
|
- (weitere Policy-spezifische Felder)
|
||||||
|
|
||||||
|
- **FlattenedSetting**: Internes Transform-Result
|
||||||
|
- `settingName`: string
|
||||||
|
- `settingValue`: string
|
||||||
|
- `settingValueType`: string
|
||||||
|
- `path`: string (Dot-Notation Pfad im Original-Objekt)
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: User erhält sofortige Bestätigung nach Click auf "Sync Now" (<200ms Response Zeit, kein Warten auf Sync-Completion).
|
||||||
|
- **SC-002**: Sync für einen Tenant mit 50 Policies ist innerhalb von 30 Sekunden abgeschlossen.
|
||||||
|
- **SC-003**: System lädt alle verfügbaren Policies vollständig (auch bei >100 Policies mit mehreren Datenseiten).
|
||||||
|
- **SC-004**: Mindestens 95% aller Policy Settings werden korrekt extrahiert und gespeichert (validiert mit repräsentativen Sample-Daten).
|
||||||
|
- **SC-005**: Bei temporären Fehlern (z.B. Service-Überlastung) erfolgt automatische Wiederholung (keine manuellen Eingriffe nötig).
|
||||||
|
- **SC-006**: Alte Bridge-Komponenten sind vollständig entfernt (keine toten Code-Pfade oder ungenutzten APIs).
|
||||||
|
- **SC-007**: Sync-Prozess läuft stabil über längere Zeiträume (1+ Stunde mit 10+ Sync-Operationen ohne Abstürze).
|
||||||
|
- **SC-008**: Re-Sync aktualisiert bestehende Daten korrekt ohne Duplikate oder Datenverluste.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- System nutzt asynchrone Job-Verarbeitung mit Queue-basierter Architektur für Skalierbarkeit.
|
||||||
|
- TenantPilot hat bereits Azure AD Multi-Tenant Authentication konfiguriert (Client Credentials verfügbar).
|
||||||
|
- Die bestehende `policy_settings` Datenbank-Tabelle hat bereits einen UNIQUE Constraint auf `(tenantId, graphPolicyId, settingName)`.
|
||||||
|
- Die Flattening-Logik aus der bisherigen n8n-Implementation ist dokumentiert oder nachvollziehbar.
|
||||||
|
- Sync-Prozess wird in Production als persistenter Background-Service betrieben (nicht nur bei Bedarf gestartet).
|
||||||
|
- Redis oder vergleichbarer In-Memory Store ist verfügbar für Job Queue Management.
|
||||||
|
|
||||||
|
## Nicht-Ziele (Out of Scope)
|
||||||
|
|
||||||
|
- Kein automatischer Scheduled Sync (zeitbasierte Trigger) in diesem Feature - bleibt manuelle Auslösung.
|
||||||
|
- Keine Web-UI für Job-Management oder Queue-Monitoring.
|
||||||
|
- Keine Live-Progress-Updates im Frontend während Sync läuft (kein Echtzeit-Status).
|
||||||
|
- Keine parallele Verarbeitung mehrerer Tenants gleichzeitig (sequenzielle Abarbeitung).
|
||||||
|
- Keine erweiterten Retry-Strategien oder Dead Letter Queues in MVP.
|
||||||
|
- Kein Policy Change Detection oder Diff-Berechnung (nur vollständiger Sync + Update bestehender Daten).
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
**Note**: Detaillierte Implementierungs-Details (Code-Beispiele, API-Calls, Architektur-Patterns) werden in einem separaten Technical Design Document oder im Planning-Phase dokumentiert. Diese Spec fokussiert sich auf das **WAS** und **WARUM**, nicht auf das **WIE**.
|
||||||
|
|
||||||
|
### High-Level Architecture Overview
|
||||||
|
|
||||||
|
**Queue-Based Sync Architecture**:
|
||||||
|
- Asynchrone Job-Verarbeitung für nicht-blockierende User Experience
|
||||||
|
- Worker-Prozess als separater Service für Sync-Operationen
|
||||||
|
- Persistente Job-Queue für Reliability und Retry-Fähigkeit
|
||||||
|
|
||||||
|
**Data Flow**:
|
||||||
|
1. User triggers sync → Job wird in Queue eingestellt
|
||||||
|
2. Worker nimmt Job aus Queue → Authentifiziert sich bei Microsoft
|
||||||
|
3. Worker fetcht Policy-Daten → Transformiert & flacht verschachtelte Strukturen ab
|
||||||
|
4. Worker speichert Daten → Upsert in Datenbank mit Conflict Resolution
|
||||||
|
|
||||||
|
**Migration Strategy**:
|
||||||
|
- Phase 1: Neue Infrastruktur aufbauen (Queue, Worker)
|
||||||
|
- Phase 2: Sync-Logik portieren (Auth, Fetch, Transform, Persist)
|
||||||
|
- Phase 3: Frontend auf neue Architektur umstellen
|
||||||
|
- Phase 4: Alte n8n-Komponenten entfernen
|
||||||
|
- Phase 5: End-to-End Validierung mit Production-Daten
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Job Queue System (z.B. BullMQ, Bee-Queue, oder vergleichbar)
|
||||||
|
- In-Memory Data Store (z.B. Redis, KeyDB, oder vergleichbar)
|
||||||
|
- Microsoft Graph API Client Library (z.B. @azure/identity oder vergleichbar)
|
||||||
|
- TypeScript Runtime für Worker-Prozess (z.B. tsx, ts-node, oder vergleichbar)
|
||||||
579
specs/005-backend-arch-pivot/tasks.md
Normal file
579
specs/005-backend-arch-pivot/tasks.md
Normal file
@ -0,0 +1,579 @@
|
|||||||
|
# Tasks: Backend Architecture Pivot
|
||||||
|
|
||||||
|
**Feature**: 005-backend-arch-pivot
|
||||||
|
**Generated**: 2025-12-09
|
||||||
|
**Total Tasks**: 66 (T001-T066)
|
||||||
|
**Spec**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md)
|
||||||
|
|
||||||
|
## Phase 1: Setup (no story label)
|
||||||
|
|
||||||
|
- [ ] T001 Confirm Dokploy-provided `REDIS_URL` and record connection string in `specs/005-backend-arch-pivot/notes.md`
|
||||||
|
- [ ] T002 Add `REDIS_URL` to local `.env.example` and project `.env` (if used) (`.env.example`)
|
||||||
|
- [ ] T003 Update `lib/env.mjs` to validate `REDIS_URL` (`lib/env.mjs`)
|
||||||
|
- [ ] T004 [P] Add npm dependencies: `bullmq`, `ioredis`, `@azure/identity` and dev `tsx` (`package.json`)
|
||||||
|
- [ ] T005 [P] Add npm script `worker:start` to `package.json` to run `tsx ./worker/index.ts` (`package.json`)
|
||||||
|
- [X] T006 [P] Create `lib/queue/redis.ts` - Redis connection wrapper reading `process.env.REDIS_URL` (`lib/queue/redis.ts`)
|
||||||
|
- [X] T007 [P] Create `lib/queue/syncQueue.ts` - Export BullMQ `Queue('intune-sync-queue')` (`lib/queue/syncQueue.ts`)
|
||||||
|
- [X] T008 Test connectivity: add a dummy job from a Node REPL/script and verify connection to provided Redis (`scripts/test-queue-connection.js`)
|
||||||
|
|
||||||
|
## Phase 2: Worker Skeleton (no story label)
|
||||||
|
|
||||||
|
- [ ] T009 Create `worker/index.ts` - minimal BullMQ `Worker` entry point (concurrency:1) (`worker/index.ts`)
|
||||||
|
- [ ] T010 Create `worker/logging.ts` - structured JSON logger used by worker (`worker/logging.ts`)
|
||||||
|
- [ ] T011 Create `worker/events.ts` - job lifecycle event handlers (completed/failed) (`worker/events.ts`)
|
||||||
|
- [ ] T012 [P] Add `npm run worker:start` integration to `README.md` with run instructions (`README.md`)
|
||||||
|
- [ ] T013 Create `worker/health.ts` - minimal health check handlers (used in docs) (`worker/health.ts`)
|
||||||
|
- [ ] T014 Smoke test: start `npm run worker:start` and verify worker connects and logs idle state (no file)
|
||||||
|
|
||||||
|
## Phase 3: US1 — Manual Policy Sync via Queue [US1]
|
||||||
|
|
||||||
|
- [ ] T015 [US1] Update `lib/actions/policySettings.ts` → implement `triggerPolicySync()` to call `syncQueue.add(...)` and return `jobId` (`lib/actions/policySettings.ts`)
|
||||||
|
- [ ] T016 [US1] Create server action wrapper if needed `app/actions/triggerPolicySync.ts` (`app/actions/triggerPolicySync.ts`)
|
||||||
|
- [ ] T017 [US1] Update `/app/search/SyncButton.tsx` to call server action and show queued toast with `jobId` (`components/search/SyncButton.tsx`)
|
||||||
|
- [ ] T018 [US1] Add API route `/api/policy-sync/status` (optional) to report job status using BullMQ `Job` API (`app/api/policy-sync/status/route.ts`)
|
||||||
|
- [ ] T019 [US1] Add simple job payload typing `types/syncJob.ts` (`types/syncJob.ts`)
|
||||||
|
- [ ] T020 [US1] Add unit test for `triggerPolicySync()` mocking `syncQueue.add` (`tests/unit/triggerPolicySync.test.ts`)
|
||||||
|
- [ ] T021 [US1] End-to-end test: UI → triggerPolicySync → job queued (integration test) (`tests/e2e/sync-button.test.ts`)
|
||||||
|
- [ ] T022 [US1] OPTIONAL [P] Document MVP scope for job status endpoint (FR-022) in `specs/005-backend-arch-pivot/notes.md` (`specs/005-backend-arch-pivot/notes.md`)
|
||||||
|
|
||||||
|
## Phase 4: US2 — Microsoft Graph Data Fetching [US2]
|
||||||
|
|
||||||
|
- [ ] T023 [US2] Create `worker/jobs/graphAuth.ts` - `getGraphAccessToken()` using `@azure/identity` (`worker/jobs/graphAuth.ts`)
|
||||||
|
- [ ] T024 [US2] Create `worker/jobs/graphFetch.ts` - `fetchFromGraph(endpoint)` with pagination following `@odata.nextLink` (`worker/jobs/graphFetch.ts`)
|
||||||
|
- [ ] T025 [US2] Implement `worker/utils/retry.ts` - exponential backoff retry helper (`worker/utils/retry.ts`)
|
||||||
|
- [ ] T026 [US2] Create integration tests mocking Graph endpoints for paginated responses (`tests/integration/graphFetch.test.ts`)
|
||||||
|
- [ ] T027 [US2] Implement rate limit handling and transient error classification in `graphFetch.ts` (`worker/jobs/graphFetch.ts`)
|
||||||
|
- [ ] T028 [US2] Add logging for Graph fetch metrics (requests, pages, duration) (`worker/logging.ts`)
|
||||||
|
- [ ] T029 [US2] Test: run `syncPolicies` job locally against mocked Graph responses (`tests/e2e/sync-with-mock-graph.test.ts`)
|
||||||
|
|
||||||
|
## Phase 5: US3 — Deep Flattening & Transformation [US3]
|
||||||
|
|
||||||
|
- [ ] T030 [US3] Create `worker/jobs/policyParser.ts` - top-level router and `parsePolicySettings()` (`worker/jobs/policyParser.ts`)
|
||||||
|
- [ ] T031 [US3] Implement Settings Catalog parser in `policyParser.ts` (`worker/jobs/policyParser.ts`)
|
||||||
|
- [ ] T032 [US3] Implement OMA-URI parser in `policyParser.ts` (`worker/jobs/policyParser.ts`)
|
||||||
|
- [ ] T033 [US3] Create `worker/utils/humanizer.ts` - `humanizeSettingId()` function (`worker/utils/humanizer.ts`)
|
||||||
|
- [ ] T034 [US3] Create normalization function `worker/jobs/normalizer.ts` to produce `PolicyInsertData[]` (`worker/jobs/normalizer.ts`)
|
||||||
|
- [ ] T035 [US3] Unit tests for parsers + humanizer with representative Graph samples (`tests/unit/policyParser.test.ts`)
|
||||||
|
|
||||||
|
## Phase 6: US3 — Database Persistence (shared, assign to US3) [US3]
|
||||||
|
|
||||||
|
- [ ] T036 [US3] Create `worker/jobs/dbUpsert.ts` - batch upsert function using Drizzle (`worker/jobs/dbUpsert.ts`)
|
||||||
|
- [ ] T037 [US3] Implement transactional upsert logic and `ON CONFLICT DO UPDATE` behavior (`worker/jobs/dbUpsert.ts`)
|
||||||
|
- [ ] T038 [US3] Add performance tuning: batch size config and bulk insert strategy (`worker/jobs/dbUpsert.ts`)
|
||||||
|
- [ ] T039 [US3] Add tests for upsert correctness (duplicates / conflict resolution) (`tests/integration/dbUpsert.test.ts`)
|
||||||
|
- [ ] T040 [US3] Add `lastSyncedAt` update on upsert (`worker/jobs/dbUpsert.ts`)
|
||||||
|
- [ ] T041 [US3] Load test: upsert 500+ policies and measure duration (`scripts/load-tests/upsert-benchmark.js`)
|
||||||
|
- [ ] T042 [US3] Instrument metrics for DB operations (timings, rows inserted/updated) (`worker/logging.ts`)
|
||||||
|
- [ ] T043 [US3] Validate data integrity end-to-end (Graph → transform → DB) (`tests/e2e/full-sync.test.ts`)
|
||||||
|
|
||||||
|
## Phase 7: US4 — Frontend Integration & Legacy Cleanup [US4]
|
||||||
|
|
||||||
|
[X] T044 [US4] Update `lib/actions/policySettings.ts` to remove n8n webhook calls and call `triggerPolicySync()` (`lib/actions/policySettings.ts`)
|
||||||
|
[X] T045 [US4] Update `app/api/policy-settings/route.ts` to be deleted or archive its behavior (`app/api/policy-settings/route.ts`)
|
||||||
|
[X] T046 [US4] Delete `app/api/admin/tenants/route.ts` (n8n polling) (`app/api/admin/tenants/route.ts`)
|
||||||
|
[X] T047 [US4] Remove `POLICY_API_SECRET` and `N8N_SYNC_WEBHOOK_URL` from `.env` and `lib/env.mjs` (`.env`, `lib/env.mjs`)
|
||||||
|
[X] T048 [US4] Grep-check: verify no remaining `n8n` references (repo-wide) (no file)
|
||||||
|
- [ ] T049 [US4] Update docs: remove n8n setup instructions and add worker notes (`docs/worker-deployment.md`)
|
||||||
|
- [ ] T050 [US4] Add migration note to `specs/002-manual-policy-sync/README.md` marking it superseded (`specs/002-manual-policy-sync/README.md`)
|
||||||
|
- [ ] T051 [US4] End-to-end QA: trigger sync from UI and confirm policies saved after cleanup (`tests/e2e/post-cleanup-sync.test.ts`)
|
||||||
|
|
||||||
|
## Phase 8: Testing & Validation (no story label)
|
||||||
|
|
||||||
|
- [ ] T052 Add unit tests for `worker/utils/humanizer.ts` and `policyParser.ts` coverage (`tests/unit/*.test.ts`)
|
||||||
|
- [ ] T053 Add integration tests for worker jobs processing (`tests/integration/worker.test.ts`)
|
||||||
|
- [ ] T054 Run load tests for large tenant (1000+ policies) and record results (`scripts/load-tests/large-tenant.js`)
|
||||||
|
- [ ] T055 Test worker stability (run 1+ hour with multiple jobs) and check memory usage (local script)
|
||||||
|
- [ ] T056 Validate all Success Criteria (SC-001 to SC-008) and document results (`specs/005-backend-arch-pivot/validation.md`)
|
||||||
|
|
||||||
|
## Phase 9: Deployment & Documentation (no story label)
|
||||||
|
|
||||||
|
- [ ] T057 Create `docs/worker-deployment.md` with production steps (`docs/worker-deployment.md`)
|
||||||
|
- [ ] T058 Add deployment config for worker (Dockerfile or PM2 config) (`deploy/worker/Dockerfile`)
|
||||||
|
- [ ] T059 Ensure `REDIS_URL` is set in production Dokploy config and documented (`deploy/README.md`)
|
||||||
|
- [ ] T060 Add monitoring & alerting for worker failures (Sentry / logs / email) (`deploy/monitoring.md`)
|
||||||
|
- [ ] T061 Run canary production sync and verify (`scripts/canary-sync.js`)
|
||||||
|
- [ ] T062 Final cleanup: remove unused n8n-related code paths and feature flags (`grep and code edits`)
|
||||||
|
- [ ] T063 Update `README.md` and `DEPLOYMENT.md` with worker instructions (`README.md`, `DEPLOYMENT.md`)
|
||||||
|
- [ ] T064 Tag release branch `005-backend-arch-pivot` and create PR template (`.github/`)
|
||||||
|
- [ ] T065 Merge PR after review and monitor first production sync (`GitHub workflow`)
|
||||||
|
- [ ] T066 Post-deploy: run post-mortem checklist and close feature ticket (`specs/005-backend-arch-pivot/closure.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tasks labeled `[P]` are safe to run in parallel across different files or developers.
|
||||||
|
- Story labels map to spec user stories: `US1` = Manual Sync, `US2` = Graph Fetching, `US3` = Transformation & DB, `US4` = Cleanup & Frontend.
|
||||||
|
- Each task includes a suggested file path to implement work; adjust as needed to match repo layout.
|
||||||
|
# Tasks: Backend Architecture Pivot
|
||||||
|
|
||||||
|
**Feature**: 005-backend-arch-pivot
|
||||||
|
**Generated**: 2025-12-09
|
||||||
|
**Total Tasks**: 64 (T001-T066)
|
||||||
|
**Spec**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup & Infrastructure (8 tasks)
|
||||||
|
|
||||||
|
**Goal**: Prepare environment, install dependencies, setup Redis and BullMQ queue infrastructure
|
||||||
|
|
||||||
|
### Environment Setup
|
||||||
|
|
||||||
|
- [ ] T001 Install Redis via Docker Compose (add redis service to docker-compose.yml)
|
||||||
|
- [ ] T002 [P] Add REDIS_URL to .env file (REDIS_URL=redis://localhost:6379)
|
||||||
|
- [ ] T003 [P] Update lib/env.mjs - Add REDIS_URL: z.string().url() to server schema
|
||||||
|
- [ ] T004 [P] Update lib/env.mjs - Add REDIS_URL to runtimeEnv object
|
||||||
|
- [ ] T005 Install npm packages: bullmq, ioredis, @azure/identity, tsx
|
||||||
|
|
||||||
|
### BullMQ Queue Infrastructure
|
||||||
|
|
||||||
|
- [X] T006 [P] Create lib/queue/redis.ts - Redis connection wrapper with IORedis
|
||||||
|
- [X] T007 [P] Create lib/queue/syncQueue.ts - BullMQ Queue definition for "intune-sync-queue"
|
||||||
|
- [X] T008 Test Redis connection and queue creation (add dummy job, verify in Redis CLI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Worker Process Skeleton (6 tasks)
|
||||||
|
|
||||||
|
**Goal**: Set up worker process entry point and basic job processing infrastructure
|
||||||
|
|
||||||
|
### Worker Setup
|
||||||
|
|
||||||
|
- [ ] T009 Create worker/index.ts - BullMQ Worker entry point with job processor
|
||||||
|
- [ ] T010 [P] Add worker:start script to package.json ("tsx watch worker/index.ts")
|
||||||
|
- [ ] T011 [P] Implement worker event handlers (completed, failed, error)
|
||||||
|
- [ ] T012 [P] Add structured logging for worker events (JSON format)
|
||||||
|
- [ ] T013 Create worker/jobs/syncPolicies.ts - Main sync orchestration function (empty skeleton)
|
||||||
|
- [ ] T014 Test worker starts successfully and listens on intune-sync-queue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Microsoft Graph Integration (9 tasks)
|
||||||
|
|
||||||
|
**Goal**: Implement Azure AD authentication and Microsoft Graph API data fetching with pagination
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- [ ] T015 Create worker/jobs/graphAuth.ts - ClientSecretCredential token acquisition
|
||||||
|
- [ ] T016 [P] Implement getGraphAccessToken() using @azure/identity
|
||||||
|
- [ ] T017 Test token acquisition returns valid access token
|
||||||
|
|
||||||
|
### Graph API Fetching
|
||||||
|
|
||||||
|
- [ ] T018 Create worker/jobs/graphFetch.ts - Microsoft Graph API client
|
||||||
|
- [ ] T019 [P] Implement fetchWithPagination() for handling @odata.nextLink
|
||||||
|
- [ ] T020 [P] Create fetchAllPolicies() to fetch from 4 endpoints in parallel
|
||||||
|
- [ ] T021 [P] Add Graph API endpoint constants (deviceConfigurations, compliancePolicies, configurationPolicies, intents)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- [ ] T022 Create worker/utils/retry.ts - Exponential backoff retry logic
|
||||||
|
- [ ] T023 Test Graph API calls with real tenant, verify pagination works for 100+ policies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Data Transformation (11 tasks)
|
||||||
|
|
||||||
|
**Goal**: Port n8n flattening logic to TypeScript, implement parsers for all policy types
|
||||||
|
|
||||||
|
### Policy Parser Core
|
||||||
|
|
||||||
|
- [ ] T024 Create worker/jobs/policyParser.ts - Main policy parsing router
|
||||||
|
- [ ] T025 [P] Implement detectPolicyType() based on @odata.type
|
||||||
|
- [ ] T026 [P] Implement parsePolicySettings() router function
|
||||||
|
|
||||||
|
### Settings Catalog Parser
|
||||||
|
|
||||||
|
- [ ] T027 Implement parseSettingsCatalog() for #microsoft.graph.deviceManagementConfigurationPolicy
|
||||||
|
- [ ] T028 [P] Implement extractValue() for different value types (simple, choice, group collection)
|
||||||
|
- [ ] T029 Handle nested settings with dot-notation path building
|
||||||
|
|
||||||
|
### OMA-URI Parser
|
||||||
|
|
||||||
|
- [ ] T030 [P] Implement parseOmaUri() for omaSettings[] arrays
|
||||||
|
- [ ] T031 [P] Handle valueType mapping (string, int, boolean)
|
||||||
|
|
||||||
|
### Humanizer & Utilities
|
||||||
|
|
||||||
|
- [ ] T032 Create worker/utils/humanizer.ts - Setting ID humanization
|
||||||
|
- [ ] T033 [P] Implement humanizeSettingId() to remove technical prefixes and format names
|
||||||
|
- [ ] T034 [P] Implement defaultEmptySetting() for policies with no settings
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- [ ] T035 Test parser with sample Graph API responses, verify >95% extraction rate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Database Persistence (7 tasks)
|
||||||
|
|
||||||
|
**Goal**: Implement Drizzle ORM upsert logic with conflict resolution
|
||||||
|
|
||||||
|
### Database Operations
|
||||||
|
|
||||||
|
- [ ] T036 Create worker/jobs/dbUpsert.ts - Drizzle ORM upsert function
|
||||||
|
- [ ] T037 [P] Implement upsertPolicySettings() with batch insert
|
||||||
|
- [ ] T038 [P] Configure onConflictDoUpdate with policy_settings_upsert_unique constraint
|
||||||
|
- [ ] T039 [P] Update lastSyncedAt timestamp on every sync
|
||||||
|
- [ ] T040 Map FlattenedSetting[] to PolicySetting insert format
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
- [ ] T041 Connect syncPolicies() orchestrator: auth → fetch → parse → upsert
|
||||||
|
- [ ] T042 Test full sync with real tenant data, verify database updates correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Frontend Integration (4 tasks)
|
||||||
|
|
||||||
|
**Goal**: Replace n8n webhook with BullMQ job creation in Server Action
|
||||||
|
|
||||||
|
### Server Action Update
|
||||||
|
|
||||||
|
- [ ] T043 Modify lib/actions/policySettings.ts - triggerPolicySync() function
|
||||||
|
- [ ] T044 Remove n8n webhook call (fetch to N8N_SYNC_WEBHOOK_URL)
|
||||||
|
- [ ] T045 Add BullMQ job creation (syncQueue.add('sync-tenant', { tenantId }))
|
||||||
|
- [ ] T046 Test end-to-end: UI click "Sync Now" → job created → worker processes → database updated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Legacy Cleanup (8 tasks)
|
||||||
|
|
||||||
|
**Goal**: Remove all n8n-related code, files, and environment variables
|
||||||
|
|
||||||
|
### File Deletion
|
||||||
|
|
||||||
|
- [ ] T047 Delete app/api/policy-settings/route.ts (n8n ingestion API)
|
||||||
|
- [ ] T048 Delete app/api/admin/tenants/route.ts (n8n polling API)
|
||||||
|
|
||||||
|
### Environment Variable Cleanup
|
||||||
|
|
||||||
|
- [ ] T049 Remove POLICY_API_SECRET from .env file
|
||||||
|
- [ ] T050 Remove N8N_SYNC_WEBHOOK_URL from .env file
|
||||||
|
- [ ] T051 Remove POLICY_API_SECRET from lib/env.mjs server schema
|
||||||
|
- [ ] T052 Remove N8N_SYNC_WEBHOOK_URL from lib/env.mjs server schema
|
||||||
|
- [ ] T053 Remove POLICY_API_SECRET from lib/env.mjs runtimeEnv
|
||||||
|
- [ ] T054 Remove N8N_SYNC_WEBHOOK_URL from lib/env.mjs runtimeEnv
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- [ ] T055 Run grep search for n8n references: grep -r "POLICY_API_SECRET\|N8N_SYNC_WEBHOOK_URL" --exclude-dir=specs → should be 0 results
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Testing & Validation (6 tasks)
|
||||||
|
|
||||||
|
**Goal**: Comprehensive testing of new architecture
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
- [ ] T056 [P] Write unit tests for humanizer.ts
|
||||||
|
- [ ] T057 [P] Write unit tests for retry.ts
|
||||||
|
- [ ] T058 [P] Write unit tests for policyParser.ts
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- [ ] T059 Write integration test for full syncPolicies() flow with mocked Graph API
|
||||||
|
- [ ] T060 Write integration test for database upsert with conflict resolution
|
||||||
|
|
||||||
|
### End-to-End Test
|
||||||
|
|
||||||
|
- [ ] T061 E2E test: Start Redis + Worker, trigger sync from UI, verify database updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: Deployment (5 tasks)
|
||||||
|
|
||||||
|
**Goal**: Deploy worker process to production environment
|
||||||
|
|
||||||
|
### Docker & Infrastructure
|
||||||
|
|
||||||
|
- [ ] T062 Update docker-compose.yml for production (Redis service with persistence)
|
||||||
|
- [ ] T063 Create Dockerfile for worker process (if separate container)
|
||||||
|
- [ ] T064 Configure worker as background service (PM2, Systemd, or Docker Compose)
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
- [ ] T065 Set REDIS_URL in production environment variables
|
||||||
|
- [ ] T066 Deploy worker, monitor logs for first production sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies Visualization
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Setup)
|
||||||
|
↓
|
||||||
|
Phase 2 (Worker Skeleton)
|
||||||
|
↓
|
||||||
|
Phase 3 (Graph Integration) ←─┐
|
||||||
|
↓ │
|
||||||
|
Phase 4 (Transformation) ──────┤
|
||||||
|
↓ │
|
||||||
|
Phase 5 (Database) ────────────┘
|
||||||
|
↓
|
||||||
|
Phase 6 (Frontend)
|
||||||
|
↓
|
||||||
|
Phase 7 (Cleanup)
|
||||||
|
↓
|
||||||
|
Phase 8 (Testing)
|
||||||
|
↓
|
||||||
|
Phase 9 (Deployment)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parallel Opportunities**:
|
||||||
|
- Phase 3 & 4 can overlap (Graph integration while building parsers)
|
||||||
|
- T002-T004 (env var updates) can be done in parallel
|
||||||
|
- T006-T007 (Redis & Queue files) can be done in parallel
|
||||||
|
- T015-T017 (auth) independent from T018-T021 (fetch)
|
||||||
|
- T056-T058 (unit tests) can be done in parallel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Details
|
||||||
|
|
||||||
|
### T001: Install Redis via Docker Compose
|
||||||
|
|
||||||
|
**File**: `docker-compose.yml`
|
||||||
|
|
||||||
|
**Action**: Add Redis service
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verification**: `docker-compose up -d redis` && `redis-cli ping` returns PONG
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T002-T004: Environment Variable Setup
|
||||||
|
|
||||||
|
**Files**: `.env`, `lib/env.mjs`
|
||||||
|
|
||||||
|
**Changes**:
|
||||||
|
1. Add `REDIS_URL=redis://localhost:6379` to `.env`
|
||||||
|
2. Add `REDIS_URL: z.string().url()` to server schema
|
||||||
|
3. Add `REDIS_URL: process.env.REDIS_URL` to runtimeEnv
|
||||||
|
|
||||||
|
**Verification**: `npm run dev` starts without env validation errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T005: Install npm Dependencies
|
||||||
|
|
||||||
|
**Command**:
|
||||||
|
```bash
|
||||||
|
npm install bullmq ioredis @azure/identity
|
||||||
|
npm install -D tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verification**: Check `package.json` for new dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T006: Create Redis Connection Wrapper
|
||||||
|
|
||||||
|
**File**: `lib/queue/redis.ts`
|
||||||
|
|
||||||
|
**Implementation**: See technical-notes.md section "BullMQ Setup"
|
||||||
|
|
||||||
|
**Exports**: `redisConnection`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T007: Create BullMQ Queue
|
||||||
|
|
||||||
|
**File**: `lib/queue/syncQueue.ts`
|
||||||
|
|
||||||
|
**Implementation**: See technical-notes.md section "BullMQ Setup"
|
||||||
|
|
||||||
|
**Exports**: `syncQueue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T009: Create Worker Entry Point
|
||||||
|
|
||||||
|
**File**: `worker/index.ts`
|
||||||
|
|
||||||
|
**Implementation**: See technical-notes.md section "Worker Implementation"
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Worker listens on `intune-sync-queue`
|
||||||
|
- Concurrency: 1 (sequential processing)
|
||||||
|
- Event handlers for completed, failed, error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T015-T016: Azure AD Token Acquisition
|
||||||
|
|
||||||
|
**File**: `worker/jobs/graphAuth.ts`
|
||||||
|
|
||||||
|
**Implementation**: See technical-notes.md section "Authentication"
|
||||||
|
|
||||||
|
**Function**: `getGraphAccessToken(): Promise<string>`
|
||||||
|
|
||||||
|
**Uses**: `@azure/identity` ClientSecretCredential
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T018-T021: Graph API Fetching
|
||||||
|
|
||||||
|
**File**: `worker/jobs/graphFetch.ts`
|
||||||
|
|
||||||
|
**Functions**:
|
||||||
|
- `fetchWithPagination<T>(url, token): Promise<T[]>`
|
||||||
|
- `fetchAllPolicies(token): Promise<Policy[]>`
|
||||||
|
|
||||||
|
**Endpoints**:
|
||||||
|
- deviceManagement/deviceConfigurations
|
||||||
|
- deviceManagement/deviceCompliancePolicies
|
||||||
|
- deviceManagement/configurationPolicies
|
||||||
|
- deviceManagement/intents
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T024-T034: Policy Parser Implementation
|
||||||
|
|
||||||
|
**File**: `worker/jobs/policyParser.ts`
|
||||||
|
|
||||||
|
**Functions**:
|
||||||
|
- `detectPolicyType(odataType: string): string`
|
||||||
|
- `parsePolicySettings(policy: any): FlattenedSetting[]`
|
||||||
|
- `parseSettingsCatalog(policy: any): FlattenedSetting[]`
|
||||||
|
- `parseOmaUri(policy: any): FlattenedSetting[]`
|
||||||
|
- `extractValue(settingInstance: any): any`
|
||||||
|
|
||||||
|
**Reference**: See technical-notes.md section "Flattening Strategy"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T036-T040: Database Upsert
|
||||||
|
|
||||||
|
**File**: `worker/jobs/dbUpsert.ts`
|
||||||
|
|
||||||
|
**Function**: `upsertPolicySettings(tenantId: string, settings: FlattenedSetting[])`
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Batch insert with Drizzle ORM
|
||||||
|
- Conflict resolution on `policy_settings_upsert_unique`
|
||||||
|
- Update `lastSyncedAt` timestamp
|
||||||
|
|
||||||
|
**Reference**: See technical-notes.md section "Database Upsert"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T043-T045: Frontend Integration
|
||||||
|
|
||||||
|
**File**: `lib/actions/policySettings.ts`
|
||||||
|
|
||||||
|
**Function**: `triggerPolicySync(tenantId: string)`
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```typescript
|
||||||
|
const response = await fetch(env.N8N_SYNC_WEBHOOK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ tenantId }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**After**:
|
||||||
|
```typescript
|
||||||
|
import { syncQueue } from '@/lib/queue/syncQueue';
|
||||||
|
|
||||||
|
const job = await syncQueue.add('sync-tenant', {
|
||||||
|
tenantId,
|
||||||
|
triggeredAt: new Date(),
|
||||||
|
});
|
||||||
|
return { jobId: job.id };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria Mapping
|
||||||
|
|
||||||
|
| Task(s) | Success Criterion |
|
||||||
|
|---------|-------------------|
|
||||||
|
| T001-T008 | SC-001: Job creation <200ms |
|
||||||
|
| T041-T042 | SC-002: Sync 50 policies in <30s |
|
||||||
|
| T019-T021 | SC-003: Pagination handles 100+ policies |
|
||||||
|
| T024-T035 | SC-004: >95% setting extraction |
|
||||||
|
| T022-T023 | SC-005: Automatic retry on 429 |
|
||||||
|
| T047-T055 | SC-006: Zero n8n references |
|
||||||
|
| T061, T066 | SC-007: Worker stable 1+ hour |
|
||||||
|
| T041-T042 | SC-008: No data loss on re-sync |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
| Phase | Tasks | Hours | Priority |
|
||||||
|
|-------|-------|-------|----------|
|
||||||
|
| 1. Setup | 8 | 1-2h | P1 |
|
||||||
|
| 2. Worker Skeleton | 6 | 2h | P1 |
|
||||||
|
| 3. Graph Integration | 9 | 4h | P1 |
|
||||||
|
| 4. Transformation | 11 | 6h | P1 |
|
||||||
|
| 5. Database | 7 | 3h | P1 |
|
||||||
|
| 6. Frontend | 4 | 2h | P1 |
|
||||||
|
| 7. Cleanup | 8 | 2h | P1 |
|
||||||
|
| 8. Testing | 6 | 4h | P1 |
|
||||||
|
| 9. Deployment | 5 | 3h | P1 |
|
||||||
|
| **Total** | **64** | **27-29h** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Task Execution Order
|
||||||
|
|
||||||
|
**Sequential Tasks** (blocking):
|
||||||
|
- T001 → T002-T004 → T005 (setup before queue)
|
||||||
|
- T006-T007 → T008 (Redis before queue test)
|
||||||
|
- T009 → T013 (worker before sync skeleton)
|
||||||
|
- T041 → T042 (integration before test)
|
||||||
|
- T043-T045 → T046 (implementation before E2E test)
|
||||||
|
|
||||||
|
**Parallel Tasks** (can be done simultaneously):
|
||||||
|
- T002, T003, T004 (env var updates)
|
||||||
|
- T006, T007 (Redis + Queue files)
|
||||||
|
- T010, T011, T012 (worker event handlers)
|
||||||
|
- T015-T017, T018-T021 (auth independent from fetch)
|
||||||
|
- T027-T029, T030-T031 (different parser types)
|
||||||
|
- T047, T048 (file deletions)
|
||||||
|
- T049-T054 (env var removals)
|
||||||
|
- T056, T057, T058 (unit tests)
|
||||||
|
|
||||||
|
### Common Pitfalls
|
||||||
|
|
||||||
|
1. **Redis Connection**: Ensure `maxRetriesPerRequest: null` for BullMQ compatibility
|
||||||
|
2. **Graph API**: Handle 429 rate limiting with exponential backoff
|
||||||
|
3. **Pagination**: Always follow `@odata.nextLink` until undefined
|
||||||
|
4. **Upsert**: Use correct constraint name `policy_settings_upsert_unique`
|
||||||
|
5. **Worker Deployment**: Don't forget `concurrency: 1` for sequential processing
|
||||||
|
|
||||||
|
### Testing Checkpoints
|
||||||
|
|
||||||
|
- After T008: Redis + Queue working
|
||||||
|
- After T014: Worker starts successfully
|
||||||
|
- After T017: Token acquisition works
|
||||||
|
- After T023: Graph API fetch with pagination works
|
||||||
|
- After T035: Parser extracts >95% of settings
|
||||||
|
- After T042: Full sync updates database
|
||||||
|
- After T046: UI → Worker → DB flow complete
|
||||||
|
- After T055: No n8n references remain
|
||||||
|
- After T061: E2E test passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Task Status**: Ready for Implementation
|
||||||
|
**Next Action**: Start with Phase 1 (T001-T008) - Setup & Infrastructure
|
||||||
615
specs/005-backend-arch-pivot/technical-notes.md
Normal file
615
specs/005-backend-arch-pivot/technical-notes.md
Normal file
@ -0,0 +1,615 @@
|
|||||||
|
# Technical Implementation Notes: Backend Architecture Pivot
|
||||||
|
|
||||||
|
**Feature**: 005-backend-arch-pivot
|
||||||
|
**Created**: 2025-12-09
|
||||||
|
**Purpose**: Detailed implementation guidance for developers (not part of business specification)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BullMQ Setup
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install bullmq ioredis
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redis Connection
|
||||||
|
|
||||||
|
**File**: `lib/queue/redis.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
import { env } from '@/lib/env.mjs';
|
||||||
|
|
||||||
|
export const redisConnection = new IORedis(env.REDIS_URL, {
|
||||||
|
maxRetriesPerRequest: null, // BullMQ requirement
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Queue Definition
|
||||||
|
|
||||||
|
**File**: `lib/queue/syncQueue.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { redisConnection } from './redis';
|
||||||
|
|
||||||
|
export const syncQueue = new Queue('intune-sync-queue', {
|
||||||
|
connection: redisConnection,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worker Implementation
|
||||||
|
|
||||||
|
### Worker Entry Point
|
||||||
|
|
||||||
|
**File**: `worker/index.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Worker } from 'bullmq';
|
||||||
|
import { redisConnection } from '@/lib/queue/redis';
|
||||||
|
import { syncPolicies } from './jobs/syncPolicies';
|
||||||
|
|
||||||
|
const worker = new Worker(
|
||||||
|
'intune-sync-queue',
|
||||||
|
async (job) => {
|
||||||
|
console.log(`Processing job ${job.id} for tenant ${job.data.tenantId}`);
|
||||||
|
await syncPolicies(job.data.tenantId);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection: redisConnection,
|
||||||
|
concurrency: 1, // Sequential processing
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.on('completed', (job) => {
|
||||||
|
console.log(`Job ${job.id} completed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('failed', (job, err) => {
|
||||||
|
console.error(`Job ${job?.id} failed:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Worker started, listening on intune-sync-queue...');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package.json Script
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"worker:start": "tsx watch worker/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync Logic Architecture
|
||||||
|
|
||||||
|
### Main Function
|
||||||
|
|
||||||
|
**File**: `worker/jobs/syncPolicies.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function syncPolicies(tenantId: string) {
|
||||||
|
// 1. Get Access Token
|
||||||
|
const token = await getGraphAccessToken();
|
||||||
|
|
||||||
|
// 2. Fetch all policy types
|
||||||
|
const policies = await fetchAllPolicies(token);
|
||||||
|
|
||||||
|
// 3. Parse & Flatten
|
||||||
|
const flattenedSettings = policies.flatMap(policy =>
|
||||||
|
parsePolicySettings(policy)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Upsert to Database
|
||||||
|
await upsertPolicySettings(tenantId, flattenedSettings);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication (Client Credentials)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ClientSecretCredential } from '@azure/identity';
|
||||||
|
|
||||||
|
async function getGraphAccessToken(): Promise<string> {
|
||||||
|
const credential = new ClientSecretCredential(
|
||||||
|
'common', // or specific tenant ID
|
||||||
|
env.AZURE_AD_CLIENT_ID,
|
||||||
|
env.AZURE_AD_CLIENT_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
const token = await credential.getToken('https://graph.microsoft.com/.default');
|
||||||
|
return token.token;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pagination Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function fetchWithPagination<T>(url: string, token: string): Promise<T[]> {
|
||||||
|
let results: T[] = [];
|
||||||
|
let nextLink: string | undefined = url;
|
||||||
|
|
||||||
|
while (nextLink) {
|
||||||
|
const response = await fetch(nextLink, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
results = results.concat(data.value);
|
||||||
|
nextLink = data['@odata.nextLink'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Graph API Endpoints
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const GRAPH_ENDPOINTS = {
|
||||||
|
deviceConfigurations: 'https://graph.microsoft.com/v1.0/deviceManagement/deviceConfigurations',
|
||||||
|
compliancePolicies: 'https://graph.microsoft.com/v1.0/deviceManagement/deviceCompliancePolicies',
|
||||||
|
configurationPolicies: 'https://graph.microsoft.com/v1.0/deviceManagement/configurationPolicies',
|
||||||
|
intents: 'https://graph.microsoft.com/v1.0/deviceManagement/intents',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchAllPolicies(token: string) {
|
||||||
|
const [configs, compliance, configPolicies, intents] = await Promise.all([
|
||||||
|
fetchWithPagination(GRAPH_ENDPOINTS.deviceConfigurations, token),
|
||||||
|
fetchWithPagination(GRAPH_ENDPOINTS.compliancePolicies, token),
|
||||||
|
fetchWithPagination(GRAPH_ENDPOINTS.configurationPolicies, token),
|
||||||
|
fetchWithPagination(GRAPH_ENDPOINTS.intents, token),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [...configs, ...compliance, ...configPolicies, ...intents];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flattening Strategy
|
||||||
|
|
||||||
|
### Settings Catalog (Most Complex)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function parseSettingsCatalog(policy: any): FlattenedSetting[] {
|
||||||
|
if (!policy.settings) return [defaultEmptySetting(policy)];
|
||||||
|
|
||||||
|
return policy.settings.flatMap(setting => {
|
||||||
|
const settingId = setting.settingInstance.settingDefinitionId;
|
||||||
|
const value = extractValue(setting.settingInstance);
|
||||||
|
|
||||||
|
return {
|
||||||
|
settingName: humanizeSettingId(settingId),
|
||||||
|
settingValue: JSON.stringify(value),
|
||||||
|
settingValueType: typeof value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractValue(settingInstance: any): any {
|
||||||
|
// Handle different value types
|
||||||
|
if (settingInstance.simpleSettingValue) {
|
||||||
|
return settingInstance.simpleSettingValue.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingInstance.choiceSettingValue) {
|
||||||
|
return settingInstance.choiceSettingValue.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingInstance.groupSettingCollectionValue) {
|
||||||
|
return settingInstance.groupSettingCollectionValue.children.map(
|
||||||
|
(child: any) => extractValue(child)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OMA-URI
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function parseOmaUri(policy: any): FlattenedSetting[] {
|
||||||
|
if (!policy.omaSettings) return [defaultEmptySetting(policy)];
|
||||||
|
|
||||||
|
return policy.omaSettings.map(oma => ({
|
||||||
|
settingName: oma.omaUri,
|
||||||
|
settingValue: oma.value,
|
||||||
|
settingValueType: oma.valueType || 'string',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Humanizer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function humanizeSettingId(id: string): string {
|
||||||
|
return id
|
||||||
|
.replace(/^device_vendor_msft_policy_config_/i, '')
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Default Empty Setting
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function defaultEmptySetting(policy: any): FlattenedSetting {
|
||||||
|
return {
|
||||||
|
policyId: policy.id,
|
||||||
|
policyName: policy.displayName,
|
||||||
|
policyType: detectPolicyType(policy['@odata.type']),
|
||||||
|
settingName: '(No settings configured)',
|
||||||
|
settingValue: '',
|
||||||
|
settingValueType: 'empty',
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Policy Type Detection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function detectPolicyType(odataType: string): string {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
'#microsoft.graph.deviceManagementConfigurationPolicy': 'configurationPolicy',
|
||||||
|
'#microsoft.graph.windows10CustomConfiguration': 'deviceConfiguration',
|
||||||
|
'#microsoft.graph.windows10EndpointProtectionConfiguration': 'endpointSecurity',
|
||||||
|
'#microsoft.graph.deviceCompliancePolicy': 'compliancePolicy',
|
||||||
|
'#microsoft.graph.windowsUpdateForBusinessConfiguration': 'windowsUpdateForBusiness',
|
||||||
|
'#microsoft.graph.iosCustomConfiguration': 'deviceConfiguration',
|
||||||
|
'#microsoft.graph.androidManagedAppProtection': 'appConfiguration',
|
||||||
|
};
|
||||||
|
|
||||||
|
return typeMap[odataType] || 'unknown';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Upsert
|
||||||
|
|
||||||
|
**File**: `worker/jobs/upsertPolicySettings.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { policySettings } from '@/lib/db/schema/policySettings';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
async function upsertPolicySettings(
|
||||||
|
tenantId: string,
|
||||||
|
settings: FlattenedSetting[]
|
||||||
|
) {
|
||||||
|
const records = settings.map(s => ({
|
||||||
|
tenantId,
|
||||||
|
graphPolicyId: s.policyId,
|
||||||
|
policyName: s.policyName,
|
||||||
|
policyType: s.policyType,
|
||||||
|
settingName: s.settingName,
|
||||||
|
settingValue: s.settingValue,
|
||||||
|
settingValueType: s.settingValueType,
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Batch insert with conflict resolution
|
||||||
|
await db.insert(policySettings)
|
||||||
|
.values(records)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
policySettings.tenantId,
|
||||||
|
policySettings.graphPolicyId,
|
||||||
|
policySettings.settingName
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
policyName: sql`EXCLUDED.policy_name`,
|
||||||
|
policyType: sql`EXCLUDED.policy_type`,
|
||||||
|
settingValue: sql`EXCLUDED.setting_value`,
|
||||||
|
settingValueType: sql`EXCLUDED.setting_value_type`,
|
||||||
|
lastSyncedAt: sql`EXCLUDED.last_synced_at`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### Server Action Update
|
||||||
|
|
||||||
|
**File**: `lib/actions/policySettings.ts`
|
||||||
|
|
||||||
|
**Before** (n8n Webhook):
|
||||||
|
```typescript
|
||||||
|
const response = await fetch(env.N8N_SYNC_WEBHOOK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tenantId }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (BullMQ Job):
|
||||||
|
```typescript
|
||||||
|
import { syncQueue } from '@/lib/queue/syncQueue';
|
||||||
|
|
||||||
|
export async function triggerPolicySync(tenantId: string) {
|
||||||
|
const job = await syncQueue.add('sync-tenant', {
|
||||||
|
tenantId,
|
||||||
|
triggeredAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
jobId: job.id,
|
||||||
|
message: 'Sync job created successfully'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### .env Changes
|
||||||
|
|
||||||
|
**Add**:
|
||||||
|
```bash
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove**:
|
||||||
|
```bash
|
||||||
|
# POLICY_API_SECRET=... (DELETE)
|
||||||
|
# N8N_SYNC_WEBHOOK_URL=... (DELETE)
|
||||||
|
```
|
||||||
|
|
||||||
|
### lib/env.mjs Updates
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createEnv } from "@t3-oss/env-nextjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const env = createEnv({
|
||||||
|
server: {
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
NEXTAUTH_SECRET: z.string().min(1),
|
||||||
|
NEXTAUTH_URL: z.string().url(),
|
||||||
|
AZURE_AD_CLIENT_ID: z.string().min(1),
|
||||||
|
AZURE_AD_CLIENT_SECRET: z.string().min(1),
|
||||||
|
REDIS_URL: z.string().url(), // ADD THIS
|
||||||
|
// REMOVE: POLICY_API_SECRET, N8N_SYNC_WEBHOOK_URL
|
||||||
|
},
|
||||||
|
client: {},
|
||||||
|
runtimeEnv: {
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
|
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||||
|
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||||
|
AZURE_AD_CLIENT_ID: process.env.AZURE_AD_CLIENT_ID,
|
||||||
|
AZURE_AD_CLIENT_SECRET: process.env.AZURE_AD_CLIENT_SECRET,
|
||||||
|
REDIS_URL: process.env.REDIS_URL, // ADD THIS
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retry & Error Handling
|
||||||
|
|
||||||
|
### Exponential Backoff
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function fetchWithRetry<T>(
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
maxRetries = 3
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
// Rate limit - exponential backoff
|
||||||
|
const delay = Math.pow(2, attempt) * 1000;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
// Don't retry on auth errors
|
||||||
|
if (error instanceof Error && error.message.includes('401')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff for transient errors
|
||||||
|
if (attempt < maxRetries - 1) {
|
||||||
|
const delay = Math.pow(2, attempt) * 1000;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Max retries exceeded');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose Setup (Optional)
|
||||||
|
|
||||||
|
**File**: `docker-compose.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
Start Redis:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d redis
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Worker as Systemd Service
|
||||||
|
|
||||||
|
**File**: `/etc/systemd/system/tenantpilot-worker.service`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=TenantPilot Policy Sync Worker
|
||||||
|
After=network.target redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/var/www/tenantpilot
|
||||||
|
ExecStart=/usr/bin/node /var/www/tenantpilot/worker/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable & Start:
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable tenantpilot-worker
|
||||||
|
sudo systemctl start tenantpilot-worker
|
||||||
|
sudo systemctl status tenantpilot-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { humanizeSettingId } from './humanizer';
|
||||||
|
|
||||||
|
describe('humanizeSettingId', () => {
|
||||||
|
it('removes device_vendor_msft_policy_config prefix', () => {
|
||||||
|
const result = humanizeSettingId('device_vendor_msft_policy_config_wifi_allowwifihotspotreporting');
|
||||||
|
expect(result).toBe('Wifi Allowwifihotspotreporting');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('syncPolicies', () => {
|
||||||
|
it('fetches and stores policies for tenant', async () => {
|
||||||
|
const testTenantId = 'test-tenant-123';
|
||||||
|
|
||||||
|
await syncPolicies(testTenantId);
|
||||||
|
|
||||||
|
const settings = await db.query.policySettings.findMany({
|
||||||
|
where: eq(policySettings.tenantId, testTenantId),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(settings.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring & Logging
|
||||||
|
|
||||||
|
### Structured Logging
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import winston from 'winston';
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: 'info',
|
||||||
|
format: winston.format.json(),
|
||||||
|
transports: [
|
||||||
|
new winston.transports.File({ filename: 'worker-error.log', level: 'error' }),
|
||||||
|
new winston.transports.File({ filename: 'worker-combined.log' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// In worker:
|
||||||
|
logger.info('Job started', { jobId: job.id, tenantId: job.data.tenantId });
|
||||||
|
logger.error('Job failed', { jobId: job.id, error: err.message });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check Endpoint
|
||||||
|
|
||||||
|
**File**: `app/api/worker-health/route.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { syncQueue } from '@/lib/queue/syncQueue';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const jobCounts = await syncQueue.getJobCounts();
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
status: 'healthy',
|
||||||
|
queue: jobCounts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{ status: 'unhealthy', error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
- [ ] Install dependencies (`bullmq`, `ioredis`, `@azure/identity`)
|
||||||
|
- [ ] Add `REDIS_URL` to `.env`
|
||||||
|
- [ ] Create `lib/queue/redis.ts` and `lib/queue/syncQueue.ts`
|
||||||
|
- [ ] Create `worker/index.ts` with BullMQ Worker
|
||||||
|
- [ ] Implement `worker/jobs/syncPolicies.ts` with full logic
|
||||||
|
- [ ] Update `lib/actions/policySettings.ts` → replace n8n webhook with BullMQ
|
||||||
|
- [ ] Remove `app/api/policy-settings/route.ts`
|
||||||
|
- [ ] Remove `app/api/admin/tenants/route.ts`
|
||||||
|
- [ ] Remove `POLICY_API_SECRET` from `.env` and `lib/env.mjs`
|
||||||
|
- [ ] Remove `N8N_SYNC_WEBHOOK_URL` from `.env` and `lib/env.mjs`
|
||||||
|
- [ ] Add `worker:start` script to `package.json`
|
||||||
|
- [ ] Test locally: Start Redis, Start Worker, Trigger Sync from UI
|
||||||
|
- [ ] Deploy Worker as background service (PM2/Systemd/Docker)
|
||||||
|
- [ ] Verify end-to-end: Job creation → Worker processing → Database updates
|
||||||
36
worker/events.ts
Normal file
36
worker/events.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Worker, Job } from 'bullmq';
|
||||||
|
import logger from './logging';
|
||||||
|
|
||||||
|
const jobStartTimes = new Map<string | number, number>();
|
||||||
|
|
||||||
|
export function attachWorkerEvents(worker: Worker) {
|
||||||
|
worker.on('active', (job: Job) => {
|
||||||
|
jobStartTimes.set(job.id, Date.now());
|
||||||
|
logger.info({ event: 'job_active', jobId: job.id, name: job.name, data: job.data });
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('completed', (job: Job) => {
|
||||||
|
const start = jobStartTimes.get(job.id) || Date.now();
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
jobStartTimes.delete(job.id);
|
||||||
|
logger.info({ event: 'job_complete', jobId: job.id, durationMs, timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('failed', (job: Job | undefined, err: Error | undefined) => {
|
||||||
|
const jobId = job?.id;
|
||||||
|
const start = jobId ? jobStartTimes.get(jobId) : undefined;
|
||||||
|
const durationMs = start ? Date.now() - start : undefined;
|
||||||
|
if (jobId) jobStartTimes.delete(jobId);
|
||||||
|
logger.error({ event: 'job_failed', jobId, error: err?.message, stack: err?.stack, durationMs });
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('progress', (job: Job, progress) => {
|
||||||
|
logger.info({ event: 'job_progress', jobId: job.id, progress });
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('error', (err: Error) => {
|
||||||
|
logger.error({ event: 'worker_error', error: err?.message, stack: err?.stack });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default attachWorkerEvents;
|
||||||
9
worker/health.ts
Normal file
9
worker/health.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export function checkHealth() {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
redisUrlPresent: !!process.env.REDIS_URL,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default checkHealth;
|
||||||
25
worker/index.ts
Normal file
25
worker/index.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { Worker } from 'bullmq';
|
||||||
|
import redisConnection from '../lib/queue/redis';
|
||||||
|
import { syncPolicies } from './jobs/syncPolicies';
|
||||||
|
import attachWorkerEvents from './events';
|
||||||
|
import logger from './logging';
|
||||||
|
|
||||||
|
const worker = new Worker(
|
||||||
|
'intune-sync-queue',
|
||||||
|
async (job) => {
|
||||||
|
logger.info({ event: 'job_start', jobId: job.id, name: job.name, data: job.data, timestamp: new Date().toISOString() });
|
||||||
|
return syncPolicies(job);
|
||||||
|
},
|
||||||
|
{ connection: (redisConnection as any), concurrency: 1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
attachWorkerEvents(worker);
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
logger.info('Shutting down worker...');
|
||||||
|
await worker.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Worker started: listening for jobs on intune-sync-queue');
|
||||||
73
worker/jobs/dbUpsert.ts
Normal file
73
worker/jobs/dbUpsert.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { db } from '../../lib/db';
|
||||||
|
import { policySettings } from '../../lib/db/schema/policySettings';
|
||||||
|
import type { NewPolicySetting } from '../../lib/db/schema/policySettings';
|
||||||
|
import type { FlattenedSetting } from './policyParser';
|
||||||
|
import logger from '../logging';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert policy settings to database with conflict resolution
|
||||||
|
*/
|
||||||
|
export async function upsertPolicySettings(
|
||||||
|
tenantId: string,
|
||||||
|
settings: FlattenedSetting[]
|
||||||
|
): Promise<{ inserted: number; updated: number }> {
|
||||||
|
if (settings.length === 0) {
|
||||||
|
logger.info({ event: 'dbUpsert:skip', reason: 'no settings to upsert' });
|
||||||
|
return { inserted: 0, updated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Convert to database insert format
|
||||||
|
const records: NewPolicySetting[] = settings.map((setting) => ({
|
||||||
|
tenantId,
|
||||||
|
policyName: setting.policyName,
|
||||||
|
policyType: setting.policyType,
|
||||||
|
settingName: setting.settingName,
|
||||||
|
settingValue: setting.settingValue,
|
||||||
|
graphPolicyId: setting.graphPolicyId,
|
||||||
|
lastSyncedAt: now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Batch upsert with conflict resolution
|
||||||
|
// Uses the unique constraint: (tenantId, graphPolicyId, settingName)
|
||||||
|
const result = await db
|
||||||
|
.insert(policySettings)
|
||||||
|
.values(records)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
policySettings.tenantId,
|
||||||
|
policySettings.graphPolicyId,
|
||||||
|
policySettings.settingName,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
policyName: policySettings.policyName,
|
||||||
|
policyType: policySettings.policyType,
|
||||||
|
settingValue: policySettings.settingValue,
|
||||||
|
lastSyncedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drizzle doesn't return row counts in all cases, so we estimate
|
||||||
|
const total = records.length;
|
||||||
|
logger.info({
|
||||||
|
event: 'dbUpsert:success',
|
||||||
|
total,
|
||||||
|
tenantId,
|
||||||
|
policies: [...new Set(settings.map(s => s.graphPolicyId))].length
|
||||||
|
});
|
||||||
|
|
||||||
|
return { inserted: total, updated: 0 };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({
|
||||||
|
event: 'dbUpsert:error',
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
tenantId,
|
||||||
|
settingsCount: settings.length
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default upsertPolicySettings;
|
||||||
19
worker/jobs/graphAuth.ts
Normal file
19
worker/jobs/graphAuth.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { ClientSecretCredential } from '@azure/identity';
|
||||||
|
|
||||||
|
const tenantId = process.env.AZURE_AD_TENANT_ID || process.env.AZURE_TENANT_ID;
|
||||||
|
const clientId = process.env.AZURE_AD_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.AZURE_AD_CLIENT_SECRET;
|
||||||
|
const GRAPH_SCOPE = 'https://graph.microsoft.com/.default';
|
||||||
|
|
||||||
|
export async function getGraphAccessToken(): Promise<string> {
|
||||||
|
if (!tenantId || !clientId || !clientSecret) {
|
||||||
|
throw new Error('Missing Azure AD credentials. Set AZURE_AD_TENANT_ID, AZURE_AD_CLIENT_ID and AZURE_AD_CLIENT_SECRET in env');
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
|
||||||
|
const token = await credential.getToken(GRAPH_SCOPE);
|
||||||
|
if (!token || !token.token) throw new Error('Failed to acquire Graph access token');
|
||||||
|
return token.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getGraphAccessToken;
|
||||||
77
worker/jobs/graphFetch.ts
Normal file
77
worker/jobs/graphFetch.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import getGraphAccessToken from './graphAuth';
|
||||||
|
import { withRetry, isTransientError } from '../utils/retry';
|
||||||
|
|
||||||
|
type GraphRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a Graph endpoint with pagination support for @odata.nextLink
|
||||||
|
* Returns an array of items aggregated across pages.
|
||||||
|
*/
|
||||||
|
export async function fetchWithPagination(
|
||||||
|
endpoint: string,
|
||||||
|
token: string,
|
||||||
|
baseUrl = 'https://graph.microsoft.com/v1.0'
|
||||||
|
): Promise<GraphRecord[]> {
|
||||||
|
const results: GraphRecord[] = [];
|
||||||
|
|
||||||
|
// Normalize URL
|
||||||
|
let url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
|
||||||
|
|
||||||
|
while (url) {
|
||||||
|
const res = await withRetry(
|
||||||
|
async () => {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle rate limiting (429)
|
||||||
|
if (response.status === 429) {
|
||||||
|
const retryAfter = response.headers.get('Retry-After');
|
||||||
|
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 60000;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
throw new Error(`429 Rate limit exceeded, retrying after ${delay}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const txt = await response.text();
|
||||||
|
const error = new Error(`Graph fetch failed: ${response.status} ${response.statusText} - ${txt}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAttempts: 3,
|
||||||
|
initialDelayMs: 1000,
|
||||||
|
shouldRetry: (error) => isTransientError(error),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (Array.isArray(json.value)) {
|
||||||
|
results.push(...json.value);
|
||||||
|
} else if (json.value !== undefined) {
|
||||||
|
// Some endpoints may return a single value
|
||||||
|
results.push(json.value as GraphRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = json['@odata.nextLink'];
|
||||||
|
if (next) url = next;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function: obtains a Graph token and fetches pages for the given endpoint.
|
||||||
|
*/
|
||||||
|
export async function fetchFromGraph(endpoint: string) {
|
||||||
|
const token = await getGraphAccessToken();
|
||||||
|
return fetchWithPagination(endpoint, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fetchFromGraph;
|
||||||
233
worker/jobs/policyParser.ts
Normal file
233
worker/jobs/policyParser.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import { humanizeSettingId } from '../utils/humanizer';
|
||||||
|
|
||||||
|
export interface FlattenedSetting {
|
||||||
|
policyName: string;
|
||||||
|
policyType: string;
|
||||||
|
settingName: string;
|
||||||
|
settingValue: string;
|
||||||
|
graphPolicyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GraphPolicy = Record<string, any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect policy type from @odata.type field
|
||||||
|
*/
|
||||||
|
export function detectPolicyType(policy: GraphPolicy): string {
|
||||||
|
const odataType = policy['@odata.type'] || '';
|
||||||
|
|
||||||
|
if (odataType.includes('deviceManagementConfigurationPolicy')) {
|
||||||
|
return 'deviceConfiguration';
|
||||||
|
}
|
||||||
|
if (odataType.includes('deviceCompliancePolicy') || odataType.includes('windows10CompliancePolicy')) {
|
||||||
|
return 'compliancePolicy';
|
||||||
|
}
|
||||||
|
if (odataType.includes('windowsUpdateForBusinessConfiguration')) {
|
||||||
|
return 'windowsUpdateForBusiness';
|
||||||
|
}
|
||||||
|
if (odataType.includes('configurationPolicy')) {
|
||||||
|
return 'endpointSecurity';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return 'deviceConfiguration';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Settings Catalog policies (deviceManagementConfigurationPolicy)
|
||||||
|
*/
|
||||||
|
function parseSettingsCatalog(policy: GraphPolicy): FlattenedSetting[] {
|
||||||
|
const results: FlattenedSetting[] = [];
|
||||||
|
const policyName = policy.name || policy.displayName || 'Unnamed Policy';
|
||||||
|
const graphPolicyId = policy.id;
|
||||||
|
const policyType = detectPolicyType(policy);
|
||||||
|
|
||||||
|
const settings = policy.settings || [];
|
||||||
|
|
||||||
|
for (const setting of settings) {
|
||||||
|
const instances = setting.settingInstance || [];
|
||||||
|
|
||||||
|
for (const instance of instances) {
|
||||||
|
const defId = instance.settingDefinitionId || '';
|
||||||
|
const settingName = humanizeSettingId(defId);
|
||||||
|
|
||||||
|
// Extract value based on value type
|
||||||
|
let value = '';
|
||||||
|
|
||||||
|
if (instance.simpleSettingValue) {
|
||||||
|
value = String(instance.simpleSettingValue.value ?? '');
|
||||||
|
} else if (instance.choiceSettingValue) {
|
||||||
|
value = String(instance.choiceSettingValue.value ?? '');
|
||||||
|
} else if (instance.simpleSettingCollectionValue) {
|
||||||
|
const values = (instance.simpleSettingCollectionValue || []).map((v: any) => v.value);
|
||||||
|
value = values.join(', ');
|
||||||
|
} else if (instance.groupSettingCollectionValue) {
|
||||||
|
// Nested group settings - flatten recursively
|
||||||
|
const children = instance.groupSettingCollectionValue || [];
|
||||||
|
for (const child of children) {
|
||||||
|
const childSettings = child.children || [];
|
||||||
|
for (const childSetting of childSettings) {
|
||||||
|
const childDefId = childSetting.settingDefinitionId || '';
|
||||||
|
const childName = humanizeSettingId(childDefId);
|
||||||
|
let childValue = '';
|
||||||
|
|
||||||
|
if (childSetting.simpleSettingValue) {
|
||||||
|
childValue = String(childSetting.simpleSettingValue.value ?? '');
|
||||||
|
} else if (childSetting.choiceSettingValue) {
|
||||||
|
childValue = String(childSetting.choiceSettingValue.value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childValue) {
|
||||||
|
results.push({
|
||||||
|
policyName,
|
||||||
|
policyType,
|
||||||
|
settingName: `${settingName} > ${childName}`,
|
||||||
|
settingValue: childValue,
|
||||||
|
graphPolicyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
value = JSON.stringify(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
results.push({
|
||||||
|
policyName,
|
||||||
|
policyType,
|
||||||
|
settingName,
|
||||||
|
settingValue: value,
|
||||||
|
graphPolicyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse OMA-URI policies (legacy deviceConfiguration)
|
||||||
|
*/
|
||||||
|
function parseOmaUri(policy: GraphPolicy): FlattenedSetting[] {
|
||||||
|
const results: FlattenedSetting[] = [];
|
||||||
|
const policyName = policy.displayName || policy.name || 'Unnamed Policy';
|
||||||
|
const graphPolicyId = policy.id;
|
||||||
|
const policyType = detectPolicyType(policy);
|
||||||
|
|
||||||
|
const omaSettings = policy.omaSettings || [];
|
||||||
|
|
||||||
|
for (const setting of omaSettings) {
|
||||||
|
const omaUri = setting.omaUri || '';
|
||||||
|
const settingName = humanizeSettingId(omaUri.split('/').pop() || omaUri);
|
||||||
|
|
||||||
|
let value = '';
|
||||||
|
|
||||||
|
if (setting.value !== undefined && setting.value !== null) {
|
||||||
|
value = String(setting.value);
|
||||||
|
} else if (setting.stringValue) {
|
||||||
|
value = setting.stringValue;
|
||||||
|
} else if (setting.intValue !== undefined) {
|
||||||
|
value = String(setting.intValue);
|
||||||
|
} else if (setting.boolValue !== undefined) {
|
||||||
|
value = String(setting.boolValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
results.push({
|
||||||
|
policyName,
|
||||||
|
policyType,
|
||||||
|
settingName,
|
||||||
|
settingValue: value,
|
||||||
|
graphPolicyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse standard property-based policies (compliance, etc.)
|
||||||
|
*/
|
||||||
|
function parseStandardProperties(policy: GraphPolicy): FlattenedSetting[] {
|
||||||
|
const results: FlattenedSetting[] = [];
|
||||||
|
const policyName = policy.displayName || policy.name || 'Unnamed Policy';
|
||||||
|
const graphPolicyId = policy.id;
|
||||||
|
const policyType = detectPolicyType(policy);
|
||||||
|
|
||||||
|
// Common properties to extract
|
||||||
|
const ignoredKeys = ['@odata.type', '@odata.context', 'id', 'displayName', 'name',
|
||||||
|
'description', 'createdDateTime', 'lastModifiedDateTime',
|
||||||
|
'version', 'assignments'];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(policy)) {
|
||||||
|
if (ignoredKeys.includes(key) || value === null || value === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingName = humanizeSettingId(key);
|
||||||
|
let settingValue = '';
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
settingValue = JSON.stringify(value);
|
||||||
|
} else {
|
||||||
|
settingValue = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingValue && settingValue !== 'false' && settingValue !== '0') {
|
||||||
|
results.push({
|
||||||
|
policyName,
|
||||||
|
policyType,
|
||||||
|
settingName,
|
||||||
|
settingValue,
|
||||||
|
graphPolicyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default empty setting for policies with no extractable settings
|
||||||
|
*/
|
||||||
|
function defaultEmptySetting(policy: GraphPolicy): FlattenedSetting[] {
|
||||||
|
const policyName = policy.displayName || policy.name || 'Unnamed Policy';
|
||||||
|
const graphPolicyId = policy.id;
|
||||||
|
const policyType = detectPolicyType(policy);
|
||||||
|
|
||||||
|
return [{
|
||||||
|
policyName,
|
||||||
|
policyType,
|
||||||
|
settingName: '(No settings found)',
|
||||||
|
settingValue: 'Policy exists but no extractable settings',
|
||||||
|
graphPolicyId,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main parser router - detects type and calls appropriate parser
|
||||||
|
*/
|
||||||
|
export function parsePolicySettings(policy: GraphPolicy): FlattenedSetting[] {
|
||||||
|
const odataType = policy['@odata.type'] || '';
|
||||||
|
|
||||||
|
// Settings Catalog
|
||||||
|
if (odataType.includes('deviceManagementConfigurationPolicy')) {
|
||||||
|
const settings = parseSettingsCatalog(policy);
|
||||||
|
return settings.length > 0 ? settings : defaultEmptySetting(policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OMA-URI based
|
||||||
|
if (policy.omaSettings && Array.isArray(policy.omaSettings) && policy.omaSettings.length > 0) {
|
||||||
|
return parseOmaUri(policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard properties
|
||||||
|
const settings = parseStandardProperties(policy);
|
||||||
|
return settings.length > 0 ? settings : defaultEmptySetting(policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default parsePolicySettings;
|
||||||
115
worker/jobs/syncPolicies.ts
Normal file
115
worker/jobs/syncPolicies.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import logger from '../logging';
|
||||||
|
import { fetchFromGraph } from './graphFetch';
|
||||||
|
import { parsePolicySettings } from './policyParser';
|
||||||
|
import { upsertPolicySettings } from './dbUpsert';
|
||||||
|
|
||||||
|
const GRAPH_ENDPOINTS = [
|
||||||
|
'/deviceManagement/deviceConfigurations',
|
||||||
|
'/deviceManagement/deviceCompliancePolicies',
|
||||||
|
'/deviceManagement/configurationPolicies',
|
||||||
|
'/deviceManagement/intents',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function syncPolicies(job: any) {
|
||||||
|
const tenantId = job?.data?.tenantId || 'default-tenant';
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
event: 'syncPolicies:start',
|
||||||
|
jobId: job?.id,
|
||||||
|
tenantId,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Fetch all policies from Graph API endpoints
|
||||||
|
logger.info({ event: 'syncPolicies:fetch:start', endpoints: GRAPH_ENDPOINTS.length });
|
||||||
|
|
||||||
|
const allPolicies = [];
|
||||||
|
for (const endpoint of GRAPH_ENDPOINTS) {
|
||||||
|
try {
|
||||||
|
const policies = await fetchFromGraph(endpoint);
|
||||||
|
allPolicies.push(...policies);
|
||||||
|
logger.info({
|
||||||
|
event: 'syncPolicies:fetch:endpoint',
|
||||||
|
endpoint,
|
||||||
|
count: policies.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({
|
||||||
|
event: 'syncPolicies:fetch:error',
|
||||||
|
endpoint,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
// Continue with other endpoints even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
event: 'syncPolicies:fetch:complete',
|
||||||
|
totalPolicies: allPolicies.length
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allPolicies.length === 0) {
|
||||||
|
logger.info({ event: 'syncPolicies:done', result: 'no policies found' });
|
||||||
|
return { processed: true, policiesFound: 0, settingsUpserted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Parse and flatten all policies
|
||||||
|
logger.info({ event: 'syncPolicies:parse:start', policies: allPolicies.length });
|
||||||
|
|
||||||
|
const allSettings = [];
|
||||||
|
for (const policy of allPolicies) {
|
||||||
|
try {
|
||||||
|
const settings = parsePolicySettings(policy);
|
||||||
|
allSettings.push(...settings);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({
|
||||||
|
event: 'syncPolicies:parse:error',
|
||||||
|
policyId: policy.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
event: 'syncPolicies:parse:complete',
|
||||||
|
totalSettings: allSettings.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Upsert to database
|
||||||
|
logger.info({ event: 'syncPolicies:upsert:start', settings: allSettings.length });
|
||||||
|
|
||||||
|
const result = await upsertPolicySettings(tenantId, allSettings);
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
event: 'syncPolicies:upsert:complete',
|
||||||
|
inserted: result.inserted,
|
||||||
|
updated: result.updated
|
||||||
|
});
|
||||||
|
|
||||||
|
// Done
|
||||||
|
logger.info({
|
||||||
|
event: 'syncPolicies:done',
|
||||||
|
jobId: job?.id,
|
||||||
|
policiesFound: allPolicies.length,
|
||||||
|
settingsUpserted: result.inserted + result.updated,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
processed: true,
|
||||||
|
policiesFound: allPolicies.length,
|
||||||
|
settingsUpserted: result.inserted + result.updated
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({
|
||||||
|
event: 'syncPolicies:error',
|
||||||
|
jobId: job?.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default syncPolicies;
|
||||||
33
worker/logging.ts
Normal file
33
worker/logging.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
function formatPayload(payload: unknown) {
|
||||||
|
if (typeof payload === 'string') return { msg: payload };
|
||||||
|
if (payload instanceof Error) return { msg: payload.message, stack: payload.stack };
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMeta = () => ({ pid: process.pid, ts: new Date().toISOString() });
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
info: (payload: unknown, meta: Record<string, unknown> = {}) => {
|
||||||
|
try {
|
||||||
|
console.log(JSON.stringify({ level: 'info', ...baseMeta(), meta, payload: formatPayload(payload) }));
|
||||||
|
} catch (e) {
|
||||||
|
console.log('INFO', payload, meta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
warn: (payload: unknown, meta: Record<string, unknown> = {}) => {
|
||||||
|
try {
|
||||||
|
console.warn(JSON.stringify({ level: 'warn', ...baseMeta(), meta, payload: formatPayload(payload) }));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('WARN', payload, meta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (payload: unknown, meta: Record<string, unknown> = {}) => {
|
||||||
|
try {
|
||||||
|
console.error(JSON.stringify({ level: 'error', ...baseMeta(), meta, payload: formatPayload(payload) }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ERROR', payload, meta);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
30
worker/utils/humanizer.ts
Normal file
30
worker/utils/humanizer.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Humanize setting IDs by removing technical prefixes and formatting
|
||||||
|
*/
|
||||||
|
export function humanizeSettingId(settingId: string): string {
|
||||||
|
if (!settingId) return settingId;
|
||||||
|
|
||||||
|
// Remove common technical prefixes
|
||||||
|
let humanized = settingId
|
||||||
|
.replace(/^device_vendor_msft_policy_config_/i, '')
|
||||||
|
.replace(/^device_vendor_msft_/i, '')
|
||||||
|
.replace(/^vendor_msft_policy_config_/i, '')
|
||||||
|
.replace(/^admx_/i, '')
|
||||||
|
.replace(/^msft_/i, '');
|
||||||
|
|
||||||
|
// Replace underscores with spaces
|
||||||
|
humanized = humanized.replace(/_/g, ' ');
|
||||||
|
|
||||||
|
// Convert camelCase to space-separated
|
||||||
|
humanized = humanized.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||||
|
|
||||||
|
// Capitalize first letter of each word
|
||||||
|
humanized = humanized
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return humanized.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default humanizeSettingId;
|
||||||
75
worker/utils/retry.ts
Normal file
75
worker/utils/retry.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
export interface RetryOptions {
|
||||||
|
maxAttempts?: number;
|
||||||
|
initialDelayMs?: number;
|
||||||
|
maxDelayMs?: number;
|
||||||
|
backoffMultiplier?: number;
|
||||||
|
shouldRetry?: (error: Error, attempt: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<RetryOptions> = {
|
||||||
|
maxAttempts: 3,
|
||||||
|
initialDelayMs: 1000,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
backoffMultiplier: 2,
|
||||||
|
shouldRetry: () => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a function with exponential backoff retry logic
|
||||||
|
*/
|
||||||
|
export async function withRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: RetryOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
if (attempt >= opts.maxAttempts || !opts.shouldRetry(lastError, attempt)) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
opts.initialDelayMs * Math.pow(opts.backoffMultiplier, attempt - 1),
|
||||||
|
opts.maxDelayMs
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Retry failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an error is transient and should be retried
|
||||||
|
*/
|
||||||
|
export function isTransientError(error: Error): boolean {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
|
||||||
|
// Network errors
|
||||||
|
if (message.includes('econnreset') ||
|
||||||
|
message.includes('enotfound') ||
|
||||||
|
message.includes('etimedout') ||
|
||||||
|
message.includes('network')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP status codes that should be retried
|
||||||
|
if (message.includes('429') || // Too Many Requests
|
||||||
|
message.includes('500') || // Internal Server Error
|
||||||
|
message.includes('502') || // Bad Gateway
|
||||||
|
message.includes('503') || // Service Unavailable
|
||||||
|
message.includes('504')) { // Gateway Timeout
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRetry;
|
||||||
Loading…
Reference in New Issue
Block a user