From 2da1619fd11039c2f18d917c04e0d17c897fd6f7 Mon Sep 17 00:00:00 2001 From: aewing Date: Fri, 19 Dec 2025 07:02:11 +0000 Subject: [PATCH] added admin login to get schema headers and json --- Estimator/.env | 2 +- SCHEMA_HEADERS.json | 53 ++++++ SCHEMA_REFERENCE.json | 295 +++++++++++++++++++++++++++++++ admin.html | 399 ++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- server.ts | 380 +++++++++++++++++++++++++++++++++++++++- 6 files changed, 1127 insertions(+), 4 deletions(-) create mode 100644 SCHEMA_HEADERS.json create mode 100644 SCHEMA_REFERENCE.json create mode 100644 admin.html diff --git a/Estimator/.env b/Estimator/.env index 2d7dc97..9295097 100644 --- a/Estimator/.env +++ b/Estimator/.env @@ -6,5 +6,5 @@ REDIRECT_URI=https://pocketbase.ccllc.pro/api/oauth2-redirect # PocketBase Configuration PB_DB=https://pocketbase.ccllc.pro -PB_COLLECTION=Job_Info_Prod +PB_COLLECTION=Job_Info_TestEnv PB_AUTH_COLLECTION=Users \ No newline at end of file diff --git a/SCHEMA_HEADERS.json b/SCHEMA_HEADERS.json new file mode 100644 index 0000000..39a02db --- /dev/null +++ b/SCHEMA_HEADERS.json @@ -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" + ] +} diff --git a/SCHEMA_REFERENCE.json b/SCHEMA_REFERENCE.json new file mode 100644 index 0000000..c9c55d0 --- /dev/null +++ b/SCHEMA_REFERENCE.json @@ -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 + } + ] +} diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..1dcb760 --- /dev/null +++ b/admin.html @@ -0,0 +1,399 @@ + + + + + + Admin - Schema Viewer + + + + + +
+
+

Admin Login

+

Superuser access

+
+ +
+
+ + +
+ +
+ + +
+ + +
+ + +
+ + + + + + + diff --git a/package.json b/package.json index 6c1cef8..b832abf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "job-list", - "version": "1.0.0-alpha1", + "version": "1.0.0", "description": "Job-List", "type": "module", "scripts": { diff --git a/server.ts b/server.ts index 4399cb5..81c5d01 100644 --- a/server.ts +++ b/server.ts @@ -32,8 +32,8 @@ app.use('/*', cors({ credentials: true })); -// PocketBase client -const pb = new PocketBase(process.env.PB_DB || 'https://pocketbase.ccllc.pro'); +// PocketBase client (fixed URL per request) +const pb = new PocketBase('https://pocketbase.ccllc.pro'); // Use user's PocketBase token function setUserPocketBaseAuth(token: string) { @@ -105,11 +105,387 @@ app.get('/', async (c) => { 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 = {}; + 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 }> = {}; + 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 = {}; + 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 }> = {}; + 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(); + 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); console.log(`Idea & Feedback Form server running at http://localhost:${PORT}`); export default { port: PORT, + hostname: '0.0.0.0', fetch: app.fetch, };