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
+
+
+
+
+
+
+
+
+
+
+
Collection Schema
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Field Name |
+ Type |
+ Required |
+ Details |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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,
};