diff --git a/Mode2Svelte/src/lib/auth/frontend.ts b/Mode2Svelte/src/lib/auth/frontend.ts index 31df980..175c211 100644 --- a/Mode2Svelte/src/lib/auth/frontend.ts +++ b/Mode2Svelte/src/lib/auth/frontend.ts @@ -55,45 +55,77 @@ export class PocketBaseAuth { * Setup login button click handler */ private setupLoginButton(): void { - const loginBtn = document.getElementById(this.config.loginBtnId); - if (!loginBtn) { - console.warn(`Login button with id "${this.config.loginBtnId}" not found`); - return; - } - - loginBtn.addEventListener('click', async (e) => { - e.preventDefault(); - await this.handleLogin(); - }); + console.log('[Auth] Setting up login button with id:', this.config.loginBtnId); + + // Retry finding button in case component hasn't rendered yet + let attempts = 0; + const setupInterval = setInterval(() => { + const loginBtn = document.getElementById(this.config.loginBtnId); + console.log(`[Auth] Attempt ${attempts + 1}: Looking for button, found:`, !!loginBtn); + + if (loginBtn) { + clearInterval(setupInterval); + // Remove any existing listeners to avoid duplicates + const newBtn = loginBtn.cloneNode(true) as HTMLElement; + loginBtn.parentNode?.replaceChild(newBtn, loginBtn); + + newBtn.addEventListener('click', async (e) => { + console.log('[Auth] Login button clicked!'); + e.preventDefault(); + e.stopPropagation(); + await this.handleLogin(); + }, false); + console.log(`✓ Login button listener attached (attempt ${attempts + 1})`); + } else if (attempts++ > 100) { + // Give up after 10 seconds (100 * 100ms) + clearInterval(setupInterval); + console.error(`Login button with id "${this.config.loginBtnId}" not found after 10 seconds. All elements:`, + Array.from(document.querySelectorAll('[id*="login"]')).map(e => e.id)); + } + }, 100); } /** * Handle login button click */ private async handleLogin(): Promise { + console.log('[Auth] handleLogin called'); const loginBtn = document.getElementById(this.config.loginBtnId) as HTMLButtonElement; const loginError = document.getElementById(this.config.loginErrorId) as HTMLElement; - if (!loginBtn || !loginError) return; + if (!loginBtn) { + console.error('[Auth] Login button not found in handleLogin!'); + return; + } + if (!loginError) { + console.error('[Auth] Login error element not found!'); + return; + } + console.log('[Auth] Starting login flow...'); loginBtn.disabled = true; loginBtn.textContent = 'Checking session...'; loginError.classList.add('hidden'); try { // Try to use existing token first + console.log('[Auth] Checking for existing token...'); const hadToken = await this.ensureUserLogged(); if (hadToken) { + console.log('[Auth] Found valid existing token, using it'); this.resetLoginButton(); return; } // Otherwise perform OAuth + console.log('[Auth] No valid token, starting OAuth flow...'); loginBtn.textContent = 'Logging in...'; + console.log('[Auth] Calling authWithOAuth2 with provider:', this.config.provider); const authData = await this.pb.collection(this.config.collection).authWithOAuth2({ provider: this.config.provider, }); + console.log('[Auth] OAuth successful, updating state'); this.updateState(authData); this.updateAuthUI(); this.callbacks.onAuthSuccess?.(this.state); @@ -101,7 +133,7 @@ export class PocketBaseAuth { console.log('✓ Logged in with', this.config.provider); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - console.error('Login failed:', message); + console.error('[Auth] Login failed:', message, error); loginError.textContent = `Login failed: ${message}`; loginError.classList.remove('hidden'); this.callbacks.onAuthFailure?.(error); diff --git a/Mode2Svelte/src/routes/+page.svelte b/Mode2Svelte/src/routes/+page.svelte index 65ba73a..5672368 100644 --- a/Mode2Svelte/src/routes/+page.svelte +++ b/Mode2Svelte/src/routes/+page.svelte @@ -1,126 +1,526 @@ -
-
- -
-

Mode2Svelte

-

Advanced Auth Testing Dashboard

-
- - - {#if error} -
- {error} +
+ {#if !authState.isAuthenticated} +
+
+

Job Info

+

Please authenticate to access job information

+ +
+
+ {:else} +
+ +
+

Job Info

- {/if} - -
-

Authentication Status

- - {#if authState.isAuthenticated && authState.user !== null} -
-
- Status: - ✓ Authenticated -
-
- User: - {authState.user?.name} -
-
- Email: - {authState.user?.email} -
+ +
+ + {#if hasVoicePermission} -
- {:else} -
-

Not authenticated. Log in to begin testing.

+ {/if} +
+ + +
+ +
+ + {#if showFiltersPanel} +
+
+ Division +
+ {#each divisions as div} + + {/each} +
+
+ +
+ Active +
+ {#each activeOptions as opt} + + {/each} +
+
+ +
+ Estimator +
+ {#each estimators as est} + + {/each} +
+
+ +
+ Status +
+ {#each statuses as stat} + + {/each} +
+
+ -
{/if} -
- -
- -
-

Graph Token

-
- - + + {#if error} +
+ {error}
+ {/if} + + +
+ {#if loading} +
Loading jobs...
+ {:else if visibleJobs.length === 0} +
No jobs found
+ {:else} + {#each visibleJobs as job (job.id)} + + {/each} + {/if}
- -
-

PocketBase Token

-
- - {#if pbTokenValid} -

✓ Token is valid

+ +
+
{authState.user?.email}
+ +
+
+ {/if} +
+ + +{#if showDetailModal && selectedJob} +
+
+ +
+ + +
+ + {#if !showNotesTab && !showFolderContents} + +
+
+ {#each Object.entries(selectedJob) as [key, value]} + {#if key !== 'id' && value !== null && value !== undefined && !key.startsWith('collectionId') && key !== 'collectionName' && key !== 'Job_Folder_Link'} +
+
+ {key} +
+
+ {typeof value === 'object' ? JSON.stringify(value) : String(value)} +
+
+ {/if} + {/each} +
+ + {#if selectedJob.Job_Folder_Link} + {/if}
+ {:else if showFolderContents} + +
+ {#if loadingFiles} +
+
Loading files...
+
+ {:else if filesError} +
+ {filesError} +
+ + {:else if jobFiles.length === 0} +
No files found in folder
+ + {:else} + + + + +
+ {#each ['managerInfo', 'contracts', 'plans', 'submittals', 'other'] as category} + {@const categoryFiles = jobFiles + .filter(f => !f.isFolder && categorizeFile(f.name) === category) + .filter(f => folderSearchFilter === '' || f.name.toLowerCase().includes(folderSearchFilter.toLowerCase())) + } + {@const categoryTitles = { + managerInfo: 'Manager Info', + contracts: 'Contracts / Estimates', + plans: 'Plans', + submittals: 'Submittals', + other: 'Other' + }} + {#if categoryFiles.length > 0} +
+
+ {categoryTitles[category]} ({categoryFiles.length}) +
+
+ {#each categoryFiles as file, idx (file.id || file.name)} + + {/each} +
+
+ {/if} + {/each} +
+ + + {/if} +
+ {:else} + +
+
+ {selectedJob.Notes || '(No notes)'} +
+ +
+ +
+ + +
+ {/if} + + +
+
+
+{/if} - -
+ +{#if showFileViewer && currentViewerFile} +
+ +
+
+
+ {currentViewerFile.name || 'File Preview'} +
+
+
- - {#if graphToken} -
-

Graph Token Response

-
{graphToken}
-
- {/if} - - {#if comparisonData} -
-

Token Comparison

-
{comparisonData}
-
- {/if} + +
+ {#if currentViewerFile.url} + + {:else} +
+
Unable to preview file
+
No URL available
+
+ {/if} +
-
+{/if} + + diff --git a/Mode2Svelte/src/routes/api/job-files/+server.ts b/Mode2Svelte/src/routes/api/job-files/+server.ts new file mode 100644 index 0000000..d90bb98 --- /dev/null +++ b/Mode2Svelte/src/routes/api/job-files/+server.ts @@ -0,0 +1,237 @@ +import type { RequestHandler } from '@sveltejs/kit'; + +const GRAPH_BASE = 'https://graph.microsoft.com/v1.0'; + +/** + * Helper to encode SharePoint links for Graph API /shares endpoint + */ +function b64Url(input: string) { + return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +/** + * Helper to fetch JSON from Graph API + */ +async function fetchJson(url: string, token: string) { + console.log('📁 Fetching:', url); + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }); + if (!res.ok) { + const text = await res.text(); + const err: any = new Error(`Graph ${res.status}: ${text}`); + err.status = res.status; + throw err; + } + return res.json(); +} + +/** + * Resolve a SharePoint folder link to driveId and itemId + * Handles both short sharing links and direct document library URLs + * Works with both RootFolder parameter format and direct path format + */ +const resolveShareLink = async (link: string, token: string) => { + // Decode the link in case it comes pre-encoded from the database + const decodedLink = decodeURIComponent(link); + console.log('[resolveShareLink] Decoded link:', decodedLink.substring(0, 150)); + + // Check if this is a short sharing link (/:f:/ or /:b:/) + if (decodedLink.includes('/:f:/') || decodedLink.includes('/:b:/')) { + // Short sharing link - use shares API + const encoded = `u!${b64Url(decodedLink)}`; + console.log('[resolveShareLink] Using shares API, encoded:', encoded); + const data = await fetchJson(`${GRAPH_BASE}/shares/${encoded}/driveItem`, token); + return { driveId: data.parentReference?.driveId as string, itemId: data.id as string }; + } else { + // Direct document library URL - parse it and use drive API + const url = new URL(decodedLink); + const pathMatch = url.hostname.match(/^([^.]+)\.sharepoint\.com$/); + if (!pathMatch) throw new Error('Invalid SharePoint URL'); + + const hostname = pathMatch[1]; // e.g., 'czflex' + + // Decode pathname to handle spaces properly + const decodedPathname = decodeURIComponent(url.pathname); + console.log('[resolveShareLink] Decoded pathname:', decodedPathname); + + const sitePath = decodedPathname.split('/Shared Documents')[0]; // e.g., /sites/Team + let folderPath: string | null = null; + + // Try RootFolder parameter first (AllItems.aspx format) + const rootFolderParam = url.searchParams.get('RootFolder'); + if (rootFolderParam) { + console.log('[resolveShareLink] Found RootFolder parameter'); + // RootFolder format: /sites/Team/Shared Documents/General/Operations [Server]/... + // We need: General/Operations [Server]/... + folderPath = rootFolderParam.split('/Shared Documents/')[1]; + if (!folderPath) throw new Error('Could not parse folder path from RootFolder'); + } else { + // No RootFolder - extract from pathname directly + // Format: /sites/Team/Shared Documents/General/Operations [Server]/1 Job Pages/... + console.log('[resolveShareLink] No RootFolder, parsing from pathname'); + const sharedDocsIndex = decodedPathname.indexOf('/Shared Documents/'); + if (sharedDocsIndex !== -1) { + const pathAfterSharedDocs = decodedPathname.substring(sharedDocsIndex + '/Shared Documents/'.length); + folderPath = pathAfterSharedDocs; + console.log('[resolveShareLink] Extracted folderPath from pathname:', folderPath); + } + } + + if (!folderPath) { + throw new Error('Could not extract folder path from URL'); + } + + console.log('[resolveShareLink] Hostname:', hostname); + console.log('[resolveShareLink] Site path:', sitePath); + console.log('[resolveShareLink] Folder path:', folderPath); + + // Get the site ID first using hostname:sitePath format + const siteData = await fetchJson(`${GRAPH_BASE}/sites/${hostname}.sharepoint.com:${sitePath}`, token); + const siteId = siteData.id; + console.log('[resolveShareLink] Site ID:', siteId); + + // Get the default document library drive + const driveData = await fetchJson(`${GRAPH_BASE}/sites/${siteId}/drive`, token); + const driveId = driveData.id; + console.log('[resolveShareLink] Drive ID:', driveId); + + // Get the folder item by path - need to properly encode the path + const encodedPath = folderPath + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/'); + + console.log('[resolveShareLink] Encoded path for Graph API:', encodedPath); + + const itemData = await fetchJson( + `${GRAPH_BASE}/drives/${driveId}/root:/${encodedPath}`, + token + ); + + console.log('[resolveShareLink] Resolved to driveId:', driveId, 'itemId:', itemData.id); + return { driveId, itemId: itemData.id }; + } +}; + +async function listChildrenRecursive( + driveId: string, + itemId: string, + depth = 0, + maxDepth = 5, + token: string, +): Promise { + const out: any[] = []; + const data = await fetchJson(`${GRAPH_BASE}/drives/${driveId}/items/${itemId}/children`, token); + for (const child of data.value || []) { + out.push(child); + if (child.folder && depth < maxDepth) { + const nested = await listChildrenRecursive(driveId, child.id, depth + 1, maxDepth, token); + out.push(...nested); + } + } + return out; +} + +function filterByCategory(items: any[], category?: string, pdfOnly: boolean = true) { + // First filter: show PDF files and Word documents (which will be converted to PDF) + let filtered = items; + if (pdfOnly) { + filtered = items.filter((i) => { + if (i.folder) return false; + const name = i.name.toLowerCase(); + const mimeType = i.file?.mimeType?.toLowerCase() || ''; + // Include PDFs, Word documents, and images + return name.endsWith('.pdf') || mimeType.includes('pdf') || + name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word') || + name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png') || + name.endsWith('.gif') || name.endsWith('.bmp') || name.endsWith('.tiff') || + name.endsWith('.webp') || mimeType.includes('image'); + }); + } + + // Second filter: category filtering (if specified) + if (!category) return filtered; + const nameHas = (name: string, words: string[]) => words.some((w) => name.includes(w)); + const lc = (s: string) => (s || '').toLowerCase(); + const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow']; + const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout']; + return filtered.filter((i) => { + const n = lc(i.name); + if (category === 'contracts') return nameHas(n, contractWords); + if (category === 'plans') return nameHas(n, planWords); + return true; + }); +} + + +export const GET: RequestHandler = async ({ url, request }) => { + try { + const link = url.searchParams.get('link'); + const category = url.searchParams.get('category'); + + if (!link) { + return new Response( + JSON.stringify({ error: 'link required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + // Get Graph token - try header first, then fall back to environment variable + const headerToken = request.headers.get('x-graph-token') || ''; + const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || ''; + const token = headerToken || envToken; + + console.log('[job-files] Header token:', headerToken ? headerToken.substring(0, 20) + '...' : 'NONE'); + console.log('[job-files] Env token:', envToken ? 'SET' : 'NOT SET'); + console.log('[job-files] Using token:', token ? token.substring(0, 20) + '...' : 'NONE'); + + if (!token) { + console.log('[job-files] No token found'); + return new Response( + JSON.stringify({ error: 'GRAPH_TOKEN missing' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } + + console.log('[job-files] Link:', link); + const { driveId, itemId } = await resolveShareLink(link, token); + + // Walk subfolders up to depth 5 + let items: any[] = []; + items = await listChildrenRecursive(driveId, itemId, 0, 5, token); + + // Filter to show only PDFs by default (can be disabled with pdfOnly=false query param) + const pdfOnly = url.searchParams.get('pdfOnly') !== 'false'; + const filtered = filterByCategory(items, category, pdfOnly); + console.log(`[job-files] Found ${items.length} total items, ${filtered.length} after filtering`); + + const mapped = filtered.map((i) => ({ + id: i.id, + name: i.name, + url: i.webUrl, + driveId: i.parentReference?.driveId || driveId, + size: i.size, + modified: i.lastModifiedDateTime, + contentType: i.file?.mimeType, + isFolder: Boolean(i.folder), + path: i.parentReference?.path || '', + })); + + return new Response( + JSON.stringify({ items: mapped, total: mapped.length, source: 'walk', driveId }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } catch (err) { + const message = (err as Error)?.message || String(err); + const status = (err as any)?.status || 500; + console.error('[job-files] ERROR:', message); + return new Response( + JSON.stringify({ error: message }), + { status: status, headers: { 'Content-Type': 'application/json' } } + ); + } +}; diff --git a/Mode2Svelte/src/routes/api/jobs/[id]/+server.ts b/Mode2Svelte/src/routes/api/jobs/[id]/+server.ts new file mode 100644 index 0000000..20e740c --- /dev/null +++ b/Mode2Svelte/src/routes/api/jobs/[id]/+server.ts @@ -0,0 +1,42 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import PocketBase from 'pocketbase'; + +const PB_DB = process.env.PB_DB || 'https://pocketbase.ccllc.pro'; + +export const GET: RequestHandler = async ({ params }) => { + try { + const pb = new PocketBase(PB_DB); + const record = await pb.collection('pbc_1407612689').getOne(params.id!); + + return new Response(JSON.stringify(record), { + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Job not found', + }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } +}; + +export const PATCH: RequestHandler = async ({ params, request }) => { + try { + const pb = new PocketBase(PB_DB); + const data = await request.json(); + + const updated = await pb.collection('pbc_1407612689').update(params.id!, data); + + return new Response(JSON.stringify(updated), { + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to update job', + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +}; diff --git a/Mode2Svelte/src/routes/api/jobs/list/+server.ts b/Mode2Svelte/src/routes/api/jobs/list/+server.ts new file mode 100644 index 0000000..e02154b --- /dev/null +++ b/Mode2Svelte/src/routes/api/jobs/list/+server.ts @@ -0,0 +1,56 @@ +import type { RequestHandler } from '@sveltejs/kit'; +import PocketBase from 'pocketbase'; + +const PB_DB = process.env.PB_DB || 'https://pocketbase.ccllc.pro'; + +export const GET: RequestHandler = async ({ url }) => { + try { + const pb = new PocketBase(PB_DB); + + const page = Number(url.searchParams.get('page')) || 1; + const perPage = Number(url.searchParams.get('perPage')) || 30; + const sort = url.searchParams.get('sort') || '-Job_Number'; + + // Build filter query from parameters + const filters: string[] = []; + const division = url.searchParams.get('division'); + const active = url.searchParams.get('active'); + const estimator = url.searchParams.get('estimator'); + const status = url.searchParams.get('status'); + const search = url.searchParams.get('search'); + + if (division) { + filters.push(`Job_Division = "${division}"`); + } + if (active) { + filters.push(`Active = ${active === 'Yes' ? 'true' : 'false'}`); + } + if (estimator) { + filters.push(`Estimator = "${estimator}"`); + } + if (status) { + filters.push(`Job_Status = "${status}"`); + } + if (search) { + filters.push(`(Job_Number ~ "${search}" || Job_Name ~ "${search}" || Company_Client ~ "${search}")`); + } + + const filterQuery = filters.length > 0 ? filters.join(' && ') : ''; + + const records = await pb.collection('pbc_1407612689').getList(page, perPage, { + sort, + filter: filterQuery, + }); + + return new Response(JSON.stringify(records), { + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to fetch jobs', + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +};