added admin login to get schema headers and json
This commit is contained in:
+1
-1
@@ -6,5 +6,5 @@ REDIRECT_URI=https://pocketbase.ccllc.pro/api/oauth2-redirect
|
|||||||
|
|
||||||
# PocketBase Configuration
|
# PocketBase Configuration
|
||||||
PB_DB=https://pocketbase.ccllc.pro
|
PB_DB=https://pocketbase.ccllc.pro
|
||||||
PB_COLLECTION=Job_Info_Prod
|
PB_COLLECTION=Job_Info_TestEnv
|
||||||
PB_AUTH_COLLECTION=Users
|
PB_AUTH_COLLECTION=Users
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"collection": "Job_Info_TestEnv",
|
||||||
|
"exportedAt": "2025-12-19T06:58:06.369Z",
|
||||||
|
"totalHeaders": 47,
|
||||||
|
"headers": [
|
||||||
|
"Active",
|
||||||
|
"Added_To_Calendar",
|
||||||
|
"Attachment_List",
|
||||||
|
"Company_Client",
|
||||||
|
"Contact_Person",
|
||||||
|
"Docs_Uploaded",
|
||||||
|
"Due_Date",
|
||||||
|
"Due_Date_Counter",
|
||||||
|
"Due_Date_Source",
|
||||||
|
"Due_Time",
|
||||||
|
"Email",
|
||||||
|
"Estimate_Approved",
|
||||||
|
"Estimate_Draft",
|
||||||
|
"Estimate_Reviewed",
|
||||||
|
"Estimator",
|
||||||
|
"Flag",
|
||||||
|
"Has_Attachments",
|
||||||
|
"Job_Address",
|
||||||
|
"Job_Codes",
|
||||||
|
"Job_Division",
|
||||||
|
"Job_Folder_Link",
|
||||||
|
"Job_Full_Name",
|
||||||
|
"Job_Name",
|
||||||
|
"Job_Number",
|
||||||
|
"Job_Number_Parent",
|
||||||
|
"Job_QB_Link",
|
||||||
|
"Job_Status",
|
||||||
|
"Job_Type",
|
||||||
|
"Note_Ref",
|
||||||
|
"Notes",
|
||||||
|
"Office_Rep",
|
||||||
|
"PSwift_Uploaded",
|
||||||
|
"Phone_Number",
|
||||||
|
"Project_Manager",
|
||||||
|
"QB_Created",
|
||||||
|
"Schedule_Confidence",
|
||||||
|
"Start_Date",
|
||||||
|
"Submission_Date",
|
||||||
|
"Tax_Exempt",
|
||||||
|
"Tax_Exempt_Docs_Uploaded",
|
||||||
|
"Voxer_Created",
|
||||||
|
"Voxer_Link",
|
||||||
|
"last_note_at",
|
||||||
|
"latest_note_summary",
|
||||||
|
"note_count",
|
||||||
|
"note_thread_text"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
{
|
||||||
|
"collection": "Job_Info_TestEnv",
|
||||||
|
"exportedAt": "2025-12-19T06:58:06.369Z",
|
||||||
|
"collectionMeta": {
|
||||||
|
"id": "pbc_3765329501",
|
||||||
|
"name": "Job_Info_TestEnv",
|
||||||
|
"type": "base",
|
||||||
|
"listRule": "",
|
||||||
|
"viewRule": "",
|
||||||
|
"createRule": "",
|
||||||
|
"updateRule": "",
|
||||||
|
"deleteRule": ""
|
||||||
|
},
|
||||||
|
"schemaType": "generated",
|
||||||
|
"totalFields": 46,
|
||||||
|
"sampleSize": 100,
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "Active",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Added_To_Calendar",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Attachment_List",
|
||||||
|
"type": "null",
|
||||||
|
"required": false,
|
||||||
|
"occurrences": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Company_Client",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Contact_Person",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Docs_Uploaded",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Due_Date",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Due_Date_Counter",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Due_Date_Source",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Due_Time",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Email",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Estimate_Approved",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Estimate_Draft",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Estimate_Reviewed",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Estimator",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Flag",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Has_Attachments",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Address",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Codes",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Division",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Folder_Link",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Full_Name",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Name",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Number",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Number_Parent",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_QB_Link",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Status",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Job_Type",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Note_Ref",
|
||||||
|
"type": "array",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Notes",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Office_Rep",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PSwift_Uploaded",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Phone_Number",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Project_Manager",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "QB_Created",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Schedule_Confidence",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Start_Date",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Submission_Date",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tax_Exempt",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tax_Exempt_Docs_Uploaded",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Voxer_Created",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Voxer_Link",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "last_note_at",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latest_note_summary",
|
||||||
|
"type": "null",
|
||||||
|
"required": false,
|
||||||
|
"occurrences": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "note_count",
|
||||||
|
"type": "number",
|
||||||
|
"required": true,
|
||||||
|
"occurrences": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "note_thread_text",
|
||||||
|
"type": "null",
|
||||||
|
"required": false,
|
||||||
|
"occurrences": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+399
@@ -0,0 +1,399 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin - Schema Viewer</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#4c51bf',
|
||||||
|
secondary: '#5b21b6',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gradient-to-br from-primary to-secondary p-4 sm:p-8 font-sans">
|
||||||
|
<!-- Admin Login -->
|
||||||
|
<div id="loginContainer" class="max-w-md mx-auto bg-white rounded-2xl shadow-2xl p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800">Admin Login</h1>
|
||||||
|
<p class="text-gray-600 text-sm mt-1">Superuser access</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="loginForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input type="email" id="email" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="admin@example.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
<input type="password" id="password" required
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full py-2 px-4 bg-gradient-to-br from-primary to-secondary text-white font-semibold rounded-lg hover:opacity-95 transition-opacity">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="loginError" class="hidden mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schema Viewer -->
|
||||||
|
<div id="schemaContainer" class="hidden max-w-4xl mx-auto bg-white rounded-2xl shadow-2xl p-8 mt-8">
|
||||||
|
<div class="flex flex-wrap gap-3 justify-between items-center mb-6">
|
||||||
|
<h2 id="schemaTitle" class="text-2xl font-bold text-gray-800">Collection Schema</h2>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<select id="collectionSelect" class="px-3 py-2 border rounded-lg text-sm">
|
||||||
|
<option value="">Select a collection…</option>
|
||||||
|
</select>
|
||||||
|
<button id="refreshSchemaBtn" type="button" class="px-3 py-2 bg-blue-600 text-white rounded-lg text-sm" disabled>Load Schema</button>
|
||||||
|
<input id="fieldName" class="px-3 py-2 border rounded-lg text-sm" placeholder="Job_Number" value="Job_Number" />
|
||||||
|
<button id="loadFieldBtn" type="button" class="px-3 py-2 bg-green-600 text-white rounded-lg text-sm" disabled>Load Field</button>
|
||||||
|
<button id="loadRecordsBtn" type="button" class="px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm" disabled>Load Records</button>
|
||||||
|
<button id="exportSchemaBtn" type="button" class="px-3 py-2 bg-orange-600 text-white rounded-lg text-sm" disabled>Export Schema</button>
|
||||||
|
<button id="logoutBtn" class="px-3 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition text-sm">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div id="metaPanel" class="text-xs text-gray-600 bg-gray-50 border rounded p-3 hidden"></div>
|
||||||
|
<table class="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b-2 border-gray-300">
|
||||||
|
<th class="text-left py-2 px-4 font-semibold text-gray-700">Field Name</th>
|
||||||
|
<th class="text-left py-2 px-4 font-semibold text-gray-700">Type</th>
|
||||||
|
<th class="text-left py-2 px-4 font-semibold text-gray-700">Required</th>
|
||||||
|
<th class="text-left py-2 px-4 font-semibold text-gray-700">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="schemaTable">
|
||||||
|
<!-- Filled by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schemaError" class="hidden mt-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const loginContainer = document.getElementById('loginContainer');
|
||||||
|
const schemaContainer = document.getElementById('schemaContainer');
|
||||||
|
const loginError = document.getElementById('loginError');
|
||||||
|
const schemaError = document.getElementById('schemaError');
|
||||||
|
const schemaTable = document.getElementById('schemaTable');
|
||||||
|
|
||||||
|
let adminToken = null;
|
||||||
|
let collections = [];
|
||||||
|
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const email = document.getElementById('email').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
loginError.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken = result.token;
|
||||||
|
console.log('✓ Admin logged in');
|
||||||
|
await fetchCollections();
|
||||||
|
loginContainer.classList.add('hidden');
|
||||||
|
schemaContainer.classList.remove('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
loginError.textContent = error.message;
|
||||||
|
loginError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const refreshBtn = document.getElementById('refreshSchemaBtn');
|
||||||
|
const fieldNameInput = document.getElementById('fieldName');
|
||||||
|
const loadFieldBtn = document.getElementById('loadFieldBtn');
|
||||||
|
const loadRecordsBtn = document.getElementById('loadRecordsBtn');
|
||||||
|
const exportSchemaBtn = document.getElementById('exportSchemaBtn');
|
||||||
|
|
||||||
|
refreshBtn.addEventListener('click', async () => {
|
||||||
|
console.log('Load Schema clicked');
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
const original = refreshBtn.textContent;
|
||||||
|
refreshBtn.textContent = 'Loading…';
|
||||||
|
try {
|
||||||
|
await fetchSchema();
|
||||||
|
} finally {
|
||||||
|
refreshBtn.textContent = original;
|
||||||
|
refreshBtn.disabled = !document.getElementById('collectionSelect').value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('collectionSelect').addEventListener('change', async (e) => {
|
||||||
|
const hasSelection = !!e.target.value;
|
||||||
|
refreshBtn.disabled = !hasSelection;
|
||||||
|
loadFieldBtn.disabled = !hasSelection;
|
||||||
|
loadRecordsBtn.disabled = !hasSelection;
|
||||||
|
exportSchemaBtn.disabled = !hasSelection;
|
||||||
|
if (hasSelection) {
|
||||||
|
console.log('Collection selected:', e.target.value);
|
||||||
|
await fetchSchema();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||||
|
adminToken = null;
|
||||||
|
schemaContainer.classList.add('hidden');
|
||||||
|
loginContainer.classList.remove('hidden');
|
||||||
|
document.getElementById('loginForm').reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchCollections() {
|
||||||
|
try {
|
||||||
|
console.log('Fetching collections…');
|
||||||
|
const response = await fetch('/api/collections', {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Collections response:', result);
|
||||||
|
if (!response.ok) throw new Error(result.message || 'Failed to fetch collections');
|
||||||
|
collections = result.items || [];
|
||||||
|
const select = document.getElementById('collectionSelect');
|
||||||
|
select.innerHTML = '<option value="">Select a collection…</option>' +
|
||||||
|
collections.map(c => `<option value="${c.name}">${c.name} (${c.type})</option>`).join('');
|
||||||
|
// Auto-select env default if present in list
|
||||||
|
try {
|
||||||
|
const envDefault = 'Job_Info_TestEnv';
|
||||||
|
const found = collections.find(c => c.name === envDefault);
|
||||||
|
if (found) {
|
||||||
|
select.value = envDefault;
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
loadFieldBtn.disabled = false;
|
||||||
|
loadRecordsBtn.disabled = false;
|
||||||
|
exportSchemaBtn.disabled = false;
|
||||||
|
await fetchSchema();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
schemaError.textContent = error.message;
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRecordsBtn.addEventListener('click', async () => {
|
||||||
|
const selected = document.getElementById('collectionSelect').value || '';
|
||||||
|
if (!selected) {
|
||||||
|
schemaError.textContent = 'Please select a collection';
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaError.classList.add('hidden');
|
||||||
|
schemaTable.innerHTML = '';
|
||||||
|
try {
|
||||||
|
console.log('Fetching records from collection:', selected);
|
||||||
|
const resp = await fetch(`/api/records?name=${encodeURIComponent(selected)}&perPage=10`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
console.log('Records response:', result);
|
||||||
|
if (!resp.ok) throw new Error(result.message || 'Failed to fetch records');
|
||||||
|
const items = result.items || [];
|
||||||
|
document.getElementById('schemaTitle').textContent = `Records: ${result.collection} (total: ${result.totalItems})`;
|
||||||
|
const metaPanel = document.getElementById('metaPanel');
|
||||||
|
metaPanel.textContent = JSON.stringify({ totalItems: result.totalItems }, null, 2);
|
||||||
|
metaPanel.classList.remove('hidden');
|
||||||
|
if (!items.length) {
|
||||||
|
schemaTable.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-500">No records found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaTable.innerHTML = items.map((rec) => `
|
||||||
|
<tr class="border-b border-gray-200 hover:bg-gray-50">
|
||||||
|
<td class="py-3 px-4 font-mono text-xs text-gray-800">${rec.id}</td>
|
||||||
|
<td class="py-3 px-4"><span class="bg-indigo-100 text-indigo-800 text-xs px-2 py-1 rounded">record</span></td>
|
||||||
|
<td class="py-3 px-4 text-xs">${rec.job_number ?? ''}</td>
|
||||||
|
<td class="py-3 px-4 text-xs text-gray-600">${new Date(rec.created).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
schemaError.textContent = error.message;
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadFieldBtn.addEventListener('click', async () => {
|
||||||
|
const selected = document.getElementById('collectionSelect').value || '';
|
||||||
|
const field = fieldNameInput.value || 'job_number';
|
||||||
|
if (!selected) {
|
||||||
|
schemaError.textContent = 'Please select a collection';
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaError.classList.add('hidden');
|
||||||
|
schemaTable.innerHTML = '';
|
||||||
|
try {
|
||||||
|
console.log('Fetching field:', field, 'from collection:', selected);
|
||||||
|
const resp = await fetch(`/api/field?name=${encodeURIComponent(selected)}&field=${encodeURIComponent(field)}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
console.log('Field response:', result);
|
||||||
|
if (!resp.ok) throw new Error(result.message || 'Failed to fetch field');
|
||||||
|
const values = result.values || [];
|
||||||
|
const def = result.definition || null;
|
||||||
|
const derived = result.derived || false;
|
||||||
|
const resolvedField = result.resolvedField || result.field;
|
||||||
|
document.getElementById('schemaTitle').textContent = `Field: ${resolvedField} (collection: ${result.collection})`;
|
||||||
|
const metaPanel = document.getElementById('metaPanel');
|
||||||
|
metaPanel.textContent = JSON.stringify({ definition: def, derived, requested: result.field, resolved: resolvedField, totalItems: result.totalItems, nonNullCount: result.count }, null, 2);
|
||||||
|
metaPanel.classList.remove('hidden');
|
||||||
|
if (!values.length) {
|
||||||
|
schemaTable.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-500">No values found for this field</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaTable.innerHTML = values.map(v => `
|
||||||
|
<tr class="border-b border-gray-200 hover:bg-gray-50">
|
||||||
|
<td class="py-3 px-4 font-mono text-sm text-gray-800">${result.field}</td>
|
||||||
|
<td class="py-3 px-4"><span class="bg-green-100 text-green-800 text-xs px-2 py-1 rounded">value</span></td>
|
||||||
|
<td class="py-3 px-4 text-sm"></td>
|
||||||
|
<td class="py-3 px-4 text-sm text-gray-600">${String(v)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
schemaError.textContent = error.message;
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchSchema() {
|
||||||
|
schemaError.classList.add('hidden');
|
||||||
|
schemaTable.innerHTML = '';
|
||||||
|
document.getElementById('metaPanel').classList.add('hidden');
|
||||||
|
const selected = document.getElementById('collectionSelect').value || '';
|
||||||
|
if (!selected) {
|
||||||
|
schemaError.textContent = 'Please select a collection';
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('schemaTitle').textContent = `Collection Schema: ${selected}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Fetching schema for:', selected);
|
||||||
|
const response = await fetch(`/api/schema?name=${encodeURIComponent(selected)}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Schema response:', result);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || 'Failed to fetch schema');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = result.schema || [];
|
||||||
|
const meta = result.meta || {};
|
||||||
|
const metaPanel = document.getElementById('metaPanel');
|
||||||
|
metaPanel.textContent = JSON.stringify(meta, null, 2);
|
||||||
|
metaPanel.classList.remove('hidden');
|
||||||
|
if (fields.length === 0) {
|
||||||
|
const derived = result.derived || [];
|
||||||
|
const generated = result.generatedSchema || [];
|
||||||
|
if (generated.length) {
|
||||||
|
schemaTable.innerHTML = generated.map(field => `
|
||||||
|
<tr class="border-b border-gray-200 hover:bg-gray-50">
|
||||||
|
<td class="py-3 px-4 font-mono text-sm text-gray-800">${field.name}</td>
|
||||||
|
<td class="py-3 px-4"><span class="bg-purple-100 text-purple-800 text-xs px-2 py-1 rounded">${field.type}</span></td>
|
||||||
|
<td class="py-3 px-4 text-sm">${field.required ? '✓' : ''}</td>
|
||||||
|
<td class="py-3 px-4 text-sm text-gray-600">generated</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (derived.length) {
|
||||||
|
schemaTable.innerHTML = derived.map(name => `
|
||||||
|
<tr class="border-b border-gray-200 hover:bg-gray-50">
|
||||||
|
<td class="py-3 px-4 font-mono text-sm text-gray-800">${name}</td>
|
||||||
|
<td class="py-3 px-4"><span class="bg-yellow-100 text-yellow-800 text-xs px-2 py-1 rounded">derived</span></td>
|
||||||
|
<td class="py-3 px-4 text-sm"></td>
|
||||||
|
<td class="py-3 px-4 text-sm text-gray-600">From sample record</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaTable.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-500">No fields found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'border-b border-gray-200 hover:bg-gray-50';
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="py-3 px-4 font-mono text-sm text-gray-800">${field.name}</td>
|
||||||
|
<td class="py-3 px-4"><span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">${field.type}</span></td>
|
||||||
|
<td class="py-3 px-4 text-sm">${field.required ? '✓' : ''}</td>
|
||||||
|
<td class="py-3 px-4 text-sm text-gray-600">${field.options ? JSON.stringify(field.options).substring(0, 60) + '...' : ''}</td>
|
||||||
|
`;
|
||||||
|
schemaTable.appendChild(row);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
schemaError.textContent = error.message;
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportSchemaBtn.addEventListener('click', async () => {
|
||||||
|
const selected = document.getElementById('collectionSelect').value || '';
|
||||||
|
if (!selected) {
|
||||||
|
schemaError.textContent = 'Please select a collection';
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schemaError.classList.add('hidden');
|
||||||
|
try {
|
||||||
|
console.log('Exporting schema for collection:', selected);
|
||||||
|
const resp = await fetch(`/api/export/schema?name=${encodeURIComponent(selected)}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${adminToken}` }
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.message || 'Export failed');
|
||||||
|
}
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${selected}-schema-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
console.log('✓ Schema exported');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
schemaError.textContent = error.message;
|
||||||
|
schemaError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "job-list",
|
"name": "job-list",
|
||||||
"version": "1.0.0-alpha1",
|
"version": "1.0.0",
|
||||||
"description": "Job-List",
|
"description": "Job-List",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ app.use('/*', cors({
|
|||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// PocketBase client
|
// PocketBase client (fixed URL per request)
|
||||||
const pb = new PocketBase(process.env.PB_DB || 'https://pocketbase.ccllc.pro');
|
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||||
|
|
||||||
// Use user's PocketBase token
|
// Use user's PocketBase token
|
||||||
function setUserPocketBaseAuth(token: string) {
|
function setUserPocketBaseAuth(token: string) {
|
||||||
@@ -105,11 +105,387 @@ app.get('/', async (c) => {
|
|||||||
return c.html(html);
|
return c.html(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin login endpoint
|
||||||
|
app.post('/api/admin/login', async (c) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = await c.req.json();
|
||||||
|
if (!email || !password) {
|
||||||
|
return c.json({ message: 'Missing email or password' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate as PocketBase superuser via `_superusers` collection
|
||||||
|
const authData = await pb.collection('_superusers').authWithPassword(email, password);
|
||||||
|
if (!authData || !authData.token) {
|
||||||
|
return c.json({ message: 'Invalid credentials' }, 401);
|
||||||
|
}
|
||||||
|
return c.json({ ok: true, token: authData.token, email: authData.record?.email, role: 'superuser' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('admin_login_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Login failed' }, 401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get collection schema (requires admin token)
|
||||||
|
app.get('/api/schema', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ message: 'Missing authorization token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
setUserPocketBaseAuth(token);
|
||||||
|
|
||||||
|
// Verify superuser token; `_superusers` can fetch schema
|
||||||
|
try {
|
||||||
|
await pb.collection('_superusers').authRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ message: 'Invalid or non-superuser token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const requestedName = url.searchParams.get('name');
|
||||||
|
const collectionName = requestedName || process.env.PB_COLLECTION || 'Job_Info_TestEnv';
|
||||||
|
const pbDb = process.env.PB_DB || 'https://pocketbase.ccllc.pro';
|
||||||
|
|
||||||
|
// Try SDK first
|
||||||
|
let schema: any[] = [];
|
||||||
|
let meta: Record<string, any> = {};
|
||||||
|
try {
|
||||||
|
const collection = await pb.collections.getOne(collectionName);
|
||||||
|
schema = Array.isArray(collection?.schema) ? collection.schema : [];
|
||||||
|
meta = {
|
||||||
|
name: collection?.name,
|
||||||
|
type: collection?.type,
|
||||||
|
listRule: collection?.listRule,
|
||||||
|
viewRule: collection?.viewRule,
|
||||||
|
createRule: collection?.createRule,
|
||||||
|
updateRule: collection?.updateRule,
|
||||||
|
deleteRule: collection?.deleteRule,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// ignore and try REST fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema.length) {
|
||||||
|
// Fallback to REST API for robustness
|
||||||
|
const resp = await fetch(`${pbDb}/api/collections/${encodeURIComponent(collectionName)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
schema = Array.isArray(data?.schema) ? data.schema : [];
|
||||||
|
meta = {
|
||||||
|
name: data?.name,
|
||||||
|
type: data?.type,
|
||||||
|
listRule: data?.listRule,
|
||||||
|
viewRule: data?.viewRule,
|
||||||
|
createRule: data?.createRule,
|
||||||
|
updateRule: data?.updateRule,
|
||||||
|
deleteRule: data?.deleteRule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema.length) {
|
||||||
|
// As a last resort, try to derive field names and generate schema from sample records
|
||||||
|
try {
|
||||||
|
const perPage = 25;
|
||||||
|
const list = await pb.collection(collectionName).getList(1, perPage);
|
||||||
|
const items = list?.items || [];
|
||||||
|
if (items.length) {
|
||||||
|
const systemKeys = new Set(['id','created','updated','collectionId','collectionName','expand']);
|
||||||
|
const keyStats: Record<string, { count: number; types: Set<string> }> = {};
|
||||||
|
for (const it of items) {
|
||||||
|
for (const k of Object.keys(it)) {
|
||||||
|
if (systemKeys.has(k)) continue;
|
||||||
|
const v = (it as any)[k];
|
||||||
|
const t = Array.isArray(v) ? 'array' : (v === null ? 'null' : typeof v);
|
||||||
|
if (!keyStats[k]) keyStats[k] = { count: 0, types: new Set() };
|
||||||
|
if (v !== undefined && v !== null) keyStats[k].count += 1;
|
||||||
|
keyStats[k].types.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const generatedSchema = Object.entries(keyStats).map(([name, stat]) => ({
|
||||||
|
name,
|
||||||
|
type: Array.from(stat.types).join('|'),
|
||||||
|
required: stat.count === items.length
|
||||||
|
}));
|
||||||
|
return c.json({ ok: true, schema: [], meta, generatedSchema, message: 'Generated schema from sample records.' });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return c.json({ ok: true, schema: [], meta, message: 'No fields found. Check collection name or permissions.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true, schema, meta });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('schema_fetch_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all collections (requires superuser token)
|
||||||
|
app.get('/api/collections', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ message: 'Missing authorization token' }, 401);
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
setUserPocketBaseAuth(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection('_superusers').authRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ message: 'Invalid or non-superuser token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await pb.collections.getFullList();
|
||||||
|
const items = list.map((c) => ({
|
||||||
|
id: (c as any).id,
|
||||||
|
name: (c as any).name,
|
||||||
|
type: (c as any).type,
|
||||||
|
schemaCount: Array.isArray((c as any).schema) ? (c as any).schema.length : 0,
|
||||||
|
}));
|
||||||
|
return c.json({ ok: true, items });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('collections_list_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get a single field definition and sample values (requires superuser token)
|
||||||
|
app.get('/api/field', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ message: 'Missing authorization token' }, 401);
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
setUserPocketBaseAuth(token);
|
||||||
|
|
||||||
|
// Verify superuser
|
||||||
|
try {
|
||||||
|
await pb.collection('_superusers').authRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ message: 'Invalid or non-superuser token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const collectionName = url.searchParams.get('name') || process.env.PB_COLLECTION || 'Job_Info_TestEnv';
|
||||||
|
const fieldNameParam = url.searchParams.get('field') || 'job_number';
|
||||||
|
const lc = (s: string) => s.toLowerCase();
|
||||||
|
const candidates = Array.from(new Set([
|
||||||
|
fieldNameParam,
|
||||||
|
lc(fieldNameParam),
|
||||||
|
fieldNameParam.toUpperCase(),
|
||||||
|
fieldNameParam.replace(/[\s-]/g, '_'),
|
||||||
|
lc(fieldNameParam.replace(/[\s-]/g, '_')),
|
||||||
|
// naive camelCase variants
|
||||||
|
fieldNameParam.replace(/[_\s-]([a-z])/g, (_, ch) => ch.toUpperCase()),
|
||||||
|
lc(fieldNameParam.replace(/[_\s-]([a-z])/g, (_, ch) => ch.toUpperCase()))
|
||||||
|
]));
|
||||||
|
|
||||||
|
let definition: any = null;
|
||||||
|
let derived = false;
|
||||||
|
let values: any[] = [];
|
||||||
|
let totalItems = 0;
|
||||||
|
let resolvedField: string | null = null;
|
||||||
|
|
||||||
|
// Try getting field definition via SDK
|
||||||
|
try {
|
||||||
|
const collection = await pb.collections.getOne(collectionName);
|
||||||
|
if (Array.isArray(collection?.schema)) {
|
||||||
|
definition = collection.schema.find((f: any) => candidates.some((cand) => lc(f?.name || '') === lc(cand))) || null;
|
||||||
|
if (definition) resolvedField = definition.name;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// If no explicit schema, derive from a sample record
|
||||||
|
if (!definition) {
|
||||||
|
try {
|
||||||
|
const list = await pb.collection(collectionName).getList(1, 1);
|
||||||
|
const sample = list?.items?.[0];
|
||||||
|
if (sample) {
|
||||||
|
const sampleKeys = Object.keys(sample);
|
||||||
|
const matchKey = sampleKeys.find(k => candidates.some(c => lc(k) === lc(c)));
|
||||||
|
if (matchKey) {
|
||||||
|
derived = true;
|
||||||
|
resolvedField = matchKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect up to 10 values for preview
|
||||||
|
try {
|
||||||
|
const list = await pb.collection(collectionName).getList(1, 1);
|
||||||
|
totalItems = list?.totalItems ?? 0;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const list = await pb.collection(collectionName).getList(1, 20);
|
||||||
|
const items = list?.items || [];
|
||||||
|
values = items.map((it: any) => {
|
||||||
|
if (resolvedField && Object.prototype.hasOwnProperty.call(it, resolvedField)) return it[resolvedField];
|
||||||
|
// last attempt: case-insensitive per record
|
||||||
|
const itKeys = Object.keys(it);
|
||||||
|
const k = itKeys.find(k => candidates.some(c => lc(k) === lc(c)));
|
||||||
|
return k ? it[k] : undefined;
|
||||||
|
}).filter((v: any) => v !== undefined && v !== null);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const nonNullCount = values.length;
|
||||||
|
return c.json({ ok: true, collection: collectionName, field: fieldNameParam, resolvedField, definition, derived, values, count: nonNullCount, totalItems });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('field_fetch_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get sample records for a collection (requires superuser token)
|
||||||
|
app.get('/api/records', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ message: 'Missing authorization token' }, 401);
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
setUserPocketBaseAuth(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection('_superusers').authRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ message: 'Invalid or non-superuser token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const collectionName = url.searchParams.get('name') || process.env.PB_COLLECTION || 'Job_Info_TestEnv';
|
||||||
|
const perPage = Math.max(1, Math.min(100, Number(url.searchParams.get('perPage') || 10)));
|
||||||
|
|
||||||
|
const firstPage = await pb.collection(collectionName).getList(1, perPage);
|
||||||
|
const items = firstPage?.items || [];
|
||||||
|
return c.json({ ok: true, collection: collectionName, totalItems: firstPage?.totalItems ?? 0, items });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('records_fetch_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve admin.html
|
||||||
|
app.get('/admin', async (c) => {
|
||||||
|
const html = await Bun.file(new URL('./admin.html', import.meta.url)).text();
|
||||||
|
return c.html(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export collection schema and headers (requires superuser token)
|
||||||
|
app.get('/api/export/schema', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ message: 'Missing authorization token' }, 401);
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
setUserPocketBaseAuth(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection('_superusers').authRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
return c.json({ message: 'Invalid or non-superuser token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const collectionName = url.searchParams.get('name') || process.env.PB_COLLECTION || 'Job_Info_TestEnv';
|
||||||
|
|
||||||
|
let schema: any[] = [];
|
||||||
|
let meta: Record<string, any> = {};
|
||||||
|
let generatedSchema: any[] = [];
|
||||||
|
|
||||||
|
// Fetch explicit schema via SDK
|
||||||
|
try {
|
||||||
|
const collection = await pb.collections.getOne(collectionName);
|
||||||
|
schema = Array.isArray(collection?.schema) ? collection.schema : [];
|
||||||
|
meta = {
|
||||||
|
id: (collection as any)?.id,
|
||||||
|
name: (collection as any)?.name,
|
||||||
|
type: (collection as any)?.type,
|
||||||
|
listRule: (collection as any)?.listRule,
|
||||||
|
viewRule: (collection as any)?.viewRule,
|
||||||
|
createRule: (collection as any)?.createRule,
|
||||||
|
updateRule: (collection as any)?.updateRule,
|
||||||
|
deleteRule: (collection as any)?.deleteRule,
|
||||||
|
};
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// If no explicit schema, generate from records
|
||||||
|
if (!schema.length) {
|
||||||
|
try {
|
||||||
|
const perPage = 100;
|
||||||
|
const list = await pb.collection(collectionName).getList(1, perPage);
|
||||||
|
const items = list?.items || [];
|
||||||
|
if (items.length) {
|
||||||
|
const systemKeys = new Set(['id','created','updated','collectionId','collectionName','expand']);
|
||||||
|
const keyStats: Record<string, { count: number; types: Set<string> }> = {};
|
||||||
|
for (const it of items) {
|
||||||
|
for (const k of Object.keys(it)) {
|
||||||
|
if (systemKeys.has(k)) continue;
|
||||||
|
const v = (it as any)[k];
|
||||||
|
const t = Array.isArray(v) ? 'array' : (v === null ? 'null' : typeof v);
|
||||||
|
if (!keyStats[k]) keyStats[k] = { count: 0, types: new Set() };
|
||||||
|
if (v !== undefined && v !== null) keyStats[k].count += 1;
|
||||||
|
keyStats[k].types.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
generatedSchema = Object.entries(keyStats).map(([name, stat]) => ({
|
||||||
|
name,
|
||||||
|
type: Array.from(stat.types).join('|'),
|
||||||
|
required: stat.count === items.length,
|
||||||
|
occurrences: stat.count,
|
||||||
|
sampleSize: items.length
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all unique headers (field names)
|
||||||
|
const headers = new Set<string>();
|
||||||
|
try {
|
||||||
|
const perPage = 100;
|
||||||
|
const list = await pb.collection(collectionName).getList(1, perPage);
|
||||||
|
const items = list?.items || [];
|
||||||
|
for (const it of items) {
|
||||||
|
for (const k of Object.keys(it)) {
|
||||||
|
if (!['id','created','updated','collectionId','collectionName','expand'].includes(k)) {
|
||||||
|
headers.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
collection: collectionName,
|
||||||
|
meta,
|
||||||
|
schema: schema.length ? schema : generatedSchema,
|
||||||
|
schemaType: schema.length ? 'explicit' : 'generated',
|
||||||
|
headers: Array.from(headers).sort(),
|
||||||
|
};
|
||||||
|
|
||||||
|
c.header('Content-Type', 'application/json');
|
||||||
|
c.header('Content-Disposition', `attachment; filename="${collectionName}-schema-${new Date().toISOString().split('T')[0]}.json"`);
|
||||||
|
return c.json(exportData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('export_schema_error', { message: (err as Error)?.message });
|
||||||
|
return c.json({ message: 'Internal error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 7500);
|
const PORT = Number(process.env.PORT || 7500);
|
||||||
|
|
||||||
console.log(`Idea & Feedback Form server running at http://localhost:${PORT}`);
|
console.log(`Idea & Feedback Form server running at http://localhost:${PORT}`);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: PORT,
|
port: PORT,
|
||||||
|
hostname: '0.0.0.0',
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user