profit-planet-frontend/src/app/admin/matrix-management/detail/hooks/search-candidate.ts
2025-11-17 22:11:53 +01:00

225 lines
7.2 KiB
TypeScript

import { authFetch } from '../../../../utils/authFetch';
export type CandidateItem = {
userId: number;
email: string;
userType: 'personal' | 'company';
name: string;
};
export type UserCandidatesResponse = {
success: boolean;
data?: {
q: string | null;
type: 'all' | 'personal' | 'company';
rootUserId: number | null;
limit: number;
offset: number;
total: number;
items: CandidateItem[];
};
message?: string;
};
export type UserCandidatesData = {
q: string | null;
type: 'all' | 'personal' | 'company';
rootUserId: number | null;
limit: number;
offset: number;
total: number;
items: CandidateItem[];
_debug?: {
endpoint: string;
query: Record<string, string>;
combo: string;
};
};
export type GetUserCandidatesParams = {
q: string;
type?: 'all' | 'personal' | 'company';
rootUserId?: number;
matrixId?: string | number;
topNodeEmail?: string;
limit?: number;
offset?: number;
};
export async function getUserCandidates(params: GetUserCandidatesParams): Promise<UserCandidatesData> {
const {
q,
type = 'all',
rootUserId,
matrixId,
topNodeEmail,
limit = 20,
offset = 0
} = params;
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
if (!base) {
console.warn('[getUserCandidates] NEXT_PUBLIC_API_BASE_URL not set. Falling back to same-origin.');
}
const qTrimmed = q.trim();
console.info('[getUserCandidates] Building candidate request', {
base,
q: qTrimmed,
typeSent: type !== 'all' ? type : undefined,
identifiers: { rootUserId, matrixId, topNodeEmail },
pagination: { limit, offset }
});
// Build identifier combinations: all -> root-only -> matrix-only -> email-only
const combos: Array<{ label: string; apply: (qs: URLSearchParams) => void }> = [];
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0;
const hasMatrix = !!matrixId;
const hasEmail = !!topNodeEmail;
if (hasRoot || hasMatrix || hasEmail) {
combos.push({
label: 'all-identifiers',
apply: (qs) => {
if (hasRoot) qs.set('rootUserId', String(rootUserId));
if (hasMatrix) qs.set('matrixId', String(matrixId));
if (hasEmail) qs.set('topNodeEmail', String(topNodeEmail));
}
});
}
if (hasRoot) combos.push({ label: 'root-only', apply: (qs) => { qs.set('rootUserId', String(rootUserId)); } });
if (hasMatrix) combos.push({ label: 'matrix-only', apply: (qs) => { qs.set('matrixId', String(matrixId)); } });
if (hasEmail) combos.push({ label: 'email-only', apply: (qs) => { qs.set('topNodeEmail', String(topNodeEmail)); } });
if (combos.length === 0) combos.push({ label: 'no-identifiers', apply: () => {} });
const endpointVariants = [
(qs: string) => `${base}/api/admin/matrix/users/candidates?${qs}`,
(qs: string) => `${base}/api/admin/matrix/user-candidates?${qs}`
];
console.debug('[getUserCandidates] Endpoint variants', endpointVariants.map(f => f('...')));
let lastError: any = null;
let lastZeroData: UserCandidatesData | null = null;
// Try each identifier combo against both endpoint variants
for (const combo of combos) {
const qs = new URLSearchParams();
qs.set('q', qTrimmed);
qs.set('limit', String(limit));
qs.set('offset', String(offset));
if (type !== 'all') qs.set('type', type);
combo.apply(qs);
const qsObj = Object.fromEntries(qs.entries());
console.debug('[getUserCandidates] Final query params', { combo: combo.label, qs: qsObj });
for (let i = 0; i < endpointVariants.length; i++) {
const url = endpointVariants[i](qs.toString());
const fetchOpts = { method: 'GET', headers: { Accept: 'application/json' } as const };
console.info('[getUserCandidates] REQUEST GET', {
url,
attempt: i + 1,
combo: combo.label,
identifiers: { rootUserId, matrixId, topNodeEmail },
params: { q: qTrimmed, type: type !== 'all' ? type : undefined, limit, offset },
fetchOpts
});
const t0 = performance.now();
const res = await authFetch(url, fetchOpts);
const t1 = performance.now();
const ct = res.headers.get('content-type') || '';
console.debug('[getUserCandidates] Response meta', {
status: res.status,
ok: res.ok,
durationMs: Math.round(t1 - t0),
contentType: ct
});
// Preview raw body (first 300 chars)
let rawPreview = '';
try {
rawPreview = await res.clone().text();
} catch {}
if (rawPreview) {
console.trace('[getUserCandidates] Raw body preview (trimmed)', rawPreview.slice(0, 300));
}
if (res.status === 404 && i < endpointVariants.length - 1) {
try {
const preview = ct.includes('application/json') ? await res.json() : await res.text();
console.warn('[getUserCandidates] 404 on endpoint variant, trying fallback', {
tried: url,
combo: combo.label,
preview: typeof preview === 'string' ? preview.slice(0, 200) : preview
});
} catch {}
continue;
}
if (!ct.includes('application/json')) {
const text = await res.text().catch(() => '');
console.error('[getUserCandidates] Non-JSON response', { status: res.status, bodyPreview: text.slice(0, 500) });
lastError = new Error(`Request failed: ${res.status}`);
break;
}
const json: UserCandidatesResponse = await res.json().catch(() => ({ success: false, message: 'Invalid JSON' } as any));
console.debug('[getUserCandidates] Parsed JSON', {
success: json?.success,
message: json?.message,
dataMeta: json?.data && {
q: json.data.q,
type: json.data.type,
total: json.data.total,
itemsCount: json.data.items?.length
}
});
if (!res.ok || !json?.success) {
console.error('[getUserCandidates] Backend reported failure', {
status: res.status,
successFlag: json?.success,
message: json?.message
});
lastError = new Error(json?.message || `Request failed: ${res.status}`);
break;
}
const dataWithDebug: UserCandidatesData = {
...json.data!,
_debug: { endpoint: url, query: qsObj, combo: combo.label }
};
if ((dataWithDebug.total || 0) > 0) {
console.info('[getUserCandidates] Success (non-empty)', {
total: dataWithDebug.total,
itemsCount: dataWithDebug.items.length,
combo: combo.label,
endpoint: url
});
return dataWithDebug;
}
// Keep last zero result but continue trying other combos/endpoints
lastZeroData = dataWithDebug;
console.info('[getUserCandidates] Success (empty)', { combo: combo.label, endpoint: url });
}
if (lastError) break; // stop on hard error
}
if (lastError) {
console.error('[getUserCandidates] Exhausted endpoint variants with error', { lastError: lastError?.message });
throw lastError;
}
// Return the last empty response (with _debug info) if everything was empty
if (lastZeroData) {
console.warn('[getUserCandidates] All combos returned empty results', { lastCombo: lastZeroData._debug });
return lastZeroData;
}
throw new Error('Request failed');
}