diff --git a/Extension/background.js b/Extension/background.js index 9038d0b..d099d67 100644 --- a/Extension/background.js +++ b/Extension/background.js @@ -3,8 +3,10 @@ const BACKEND_URL = "http://localhost:5173"; // TODO: Backend URL konfigurieren const PARSE_TIMEOUT_MS = 15000; // 15 seconds const SCAN_TIMEOUT_MS = 45000; // 45 seconds (listing with _ipg=240 can be slow) +const ACCOUNT_EXTENDED_TIMEOUT_MS = 15000; // 15 seconds per sub-scan const activeParseRequests = new Map(); // Map const activeScanRequests = new Map(); // Map; +const activeExtendedParseRequests = new Map(); // Map /** Aktueller Scan-Fortschritt für GET_SCAN_PROGRESS (Polling durch Web-App) */ let currentScanProgress = null; @@ -68,6 +70,11 @@ chrome.runtime.onMessageExternal.addListener((msg, sender, sendResponse) => { return true; // async } + if (msg?.action === "PARSE_ACCOUNT_EXTENDED" && msg.url) { + handleParseAccountExtendedRequest(msg.url, sendResponse); + return true; // async + } + if (msg?.action === "GET_SCAN_PROGRESS") { sendResponse(currentScanProgress ?? { percent: 0, phase: "idle", total: 0, current: 0, complete: false }); return false; @@ -676,6 +683,284 @@ async function cleanupScanRequest(tabId, data, error) { } +/** + * Handles extended account parsing request + * Visits multiple URLs (base, feedback, about, store) and collects partial results + */ +async function handleParseAccountExtendedRequest(url, sendResponse) { + try { + // Validate URL + if (!url || typeof url !== 'string' || !url.toLowerCase().includes('ebay.')) { + sendResponse({ ok: false, error: "Invalid eBay URL" }); + return; + } + + console.log("[BACKGROUND] Starting extended account parse for:", url); + + const requestId = `extended_${Date.now()}_${Math.random()}`; + const results = { + base: { ok: false, data: null, error: null }, + feedback: { ok: false, data: null, error: null }, + about: { ok: false, data: null, error: null }, + store: { ok: false, data: null, error: null } + }; + + activeExtendedParseRequests.set(requestId, { + sendResponse: sendResponse, + results: results + }); + + // Step 1: Parse base URL (like normal parse) + try { + const baseData = await parseSingleTab(url, "PARSE_EBAY", ACCOUNT_EXTENDED_TIMEOUT_MS); + results.base = { ok: true, data: baseData, error: null }; + } catch (error) { + console.warn("[BACKGROUND] Base parse failed:", error); + results.base = { ok: false, data: null, error: error.message || "Base parse failed" }; + } + + // Step 2: Parse feedback tab + try { + const feedbackUrl = appendQueryParam(url, "_tab=feedback"); + const feedbackData = await parseSingleTab(feedbackUrl, "PARSE_FEEDBACK", ACCOUNT_EXTENDED_TIMEOUT_MS); + results.feedback = { ok: true, data: feedbackData, error: null }; + } catch (error) { + console.warn("[BACKGROUND] Feedback parse failed:", error); + results.feedback = { ok: false, data: null, error: error.message || "Feedback parse failed" }; + } + + // Step 3: Parse about tab + try { + const aboutUrl = appendQueryParam(url, "_tab=about"); + const aboutData = await parseSingleTab(aboutUrl, "PARSE_ABOUT", ACCOUNT_EXTENDED_TIMEOUT_MS); + results.about = { ok: true, data: aboutData, error: null }; + } catch (error) { + console.warn("[BACKGROUND] About parse failed:", error); + results.about = { ok: false, data: null, error: error.message || "About parse failed" }; + } + + // Step 4: Find and parse store URL + try { + let storeUrl = null; + + // First, try to find store link on base page + if (results.base.ok) { + try { + // Re-open base page tab to search for store link + const baseTab = await chrome.tabs.create({ + url: url, + active: false + }); + + await new Promise((resolve) => { + const checkLoaded = (tabId, changeInfo) => { + if (tabId === baseTab.id && changeInfo.status === 'complete') { + chrome.tabs.onUpdated.removeListener(checkLoaded); + setTimeout(resolve, 2000); + } + }; + chrome.tabs.onUpdated.addListener(checkLoaded); + setTimeout(resolve, 5000); // Max wait + }); + + const injected = await ensureContentScriptInjected(baseTab.id); + if (injected) { + try { + const linkResponse = await chrome.tabs.sendMessage(baseTab.id, { action: "FIND_STORE_LINK" }); + if (linkResponse && linkResponse.ok && linkResponse.data && linkResponse.data.storeUrl) { + storeUrl = linkResponse.data.storeUrl; + } + } catch (e) { + console.warn("[BACKGROUND] Could not find store link via content script:", e); + } + } + + await chrome.tabs.remove(baseTab.id); + } catch (e) { + console.warn("[BACKGROUND] Error searching for store link:", e); + } + } + + // Fallback: heuristical derivation + if (!storeUrl) { + const sellerId = results.base.data?.sellerId || null; + storeUrl = deriveStoreUrl(url, sellerId); + } + + if (storeUrl) { + const storeData = await parseSingleTab(storeUrl, "PARSE_STORE", ACCOUNT_EXTENDED_TIMEOUT_MS); + results.store = { ok: true, data: storeData, error: null }; + } else { + results.store = { ok: false, data: null, error: "Could not determine store URL" }; + } + } catch (error) { + console.warn("[BACKGROUND] Store parse failed:", error); + results.store = { ok: false, data: null, error: error.message || "Store parse failed" }; + } + + // Combine all results + const combinedData = { + // Base data (always present if base.ok) + sellerId: results.base.data?.sellerId || "", + shopName: results.base.data?.shopName || "", + market: results.base.data?.market || "US", + status: results.base.data?.status || "unknown", + stats: results.base.data?.stats || {}, + // Extended data (can be null) + responseTimeHours: results.about.data?.responseTimeHours || null, + followers: results.store.data?.followers || null, + feedbackTotal: results.feedback.data?.feedbackTotal || null, + feedback12mPositive: results.feedback.data?.feedback12mPositive || null, + feedback12mNeutral: results.feedback.data?.feedback12mNeutral || null, + feedback12mNegative: results.feedback.data?.feedback12mNegative || null, + // Partial results status + partialResults: { + base: { ok: results.base.ok, error: results.base.error }, + feedback: { ok: results.feedback.ok, error: results.feedback.error }, + about: { ok: results.about.ok, error: results.about.error }, + store: { ok: results.store.ok, error: results.store.error } + } + }; + + activeExtendedParseRequests.delete(requestId); + sendResponse({ ok: true, data: combinedData }); + + } catch (error) { + console.error("Error in handleParseAccountExtendedRequest:", error); + sendResponse({ ok: false, error: error.message || "Unknown error" }); + } +} + +/** + * Parses a single tab with given action and timeout + * @param {string} url - URL to parse + * @param {string} action - Action to send to content script (PARSE_EBAY, PARSE_FEEDBACK, etc.) + * @param {number} timeoutMs - Timeout in milliseconds + * @returns {Promise} Parsed data + */ +async function parseSingleTab(url, action, timeoutMs) { + return new Promise(async (resolve, reject) => { + let tabId = null; + let timeoutId = null; + + try { + // Create hidden tab + const tab = await chrome.tabs.create({ + url: url, + active: false + }); + tabId = tab.id; + + // Set up timeout + timeoutId = setTimeout(() => { + cleanupSingleTab(tabId); + reject(new Error("timeout")); + }, timeoutMs); + + // Wait for tab to load + const checkTabLoaded = (updatedTabId, changeInfo) => { + if (updatedTabId !== tabId) return; + if (changeInfo.status === 'complete') { + chrome.tabs.onUpdated.removeListener(checkTabLoaded); + setTimeout(async () => { + try { + const injected = await ensureContentScriptInjected(tabId); + if (!injected) { + cleanupSingleTab(tabId); + reject(new Error("Could not inject content script")); + return; + } + + const response = await chrome.tabs.sendMessage(tabId, { action: action }); + cleanupSingleTab(tabId); + + if (response && response.ok && response.data) { + resolve(response.data); + } else { + reject(new Error(response?.error || "Parsing failed")); + } + } catch (err) { + cleanupSingleTab(tabId); + reject(err); + } + }, 2000); // 2 second delay for DOM ready + } + }; + + chrome.tabs.onUpdated.addListener(checkTabLoaded); + } catch (error) { + if (timeoutId) clearTimeout(timeoutId); + if (tabId) cleanupSingleTab(tabId); + reject(error); + } + }); +} + +/** + * Cleans up a single tab + */ +async function cleanupSingleTab(tabId) { + try { + await chrome.tabs.remove(tabId); + } catch (err) { + // Tab might already be closed + console.warn("[BACKGROUND] Could not close tab:", err); + } +} + +/** + * Appends a query parameter to a URL + * @param {string} url - Base URL + * @param {string} param - Query parameter (e.g. "_tab=feedback") + * @returns {string} URL with appended parameter + */ +function appendQueryParam(url, param) { + try { + const urlObj = new URL(url); + urlObj.search += (urlObj.search ? '&' : '?') + param; + return urlObj.href; + } catch (e) { + // Fallback: simple string append + return url + (url.includes('?') ? '&' : '?') + param; + } +} + +/** + * Derives store URL from account URL + * @param {string} accountUrl - Account URL + * @param {string|null} sellerId - Seller ID if available + * @returns {string|null} Store URL or null + */ +function deriveStoreUrl(accountUrl, sellerId) { + try { + const urlObj = new URL(accountUrl); + const pathname = urlObj.pathname; + + // Try to extract seller ID from URL if not provided + let id = sellerId; + if (!id) { + const usrMatch = pathname.match(/\/usr\/([^\/\?]+)/); + if (usrMatch && usrMatch[1]) { + id = usrMatch[1]; + } else { + const strMatch = pathname.match(/\/str\/([^\/\?]+)/); + if (strMatch && strMatch[1]) { + id = strMatch[1]; + } + } + } + + if (id) { + return `${urlObj.protocol}//${urlObj.hostname}/str/${encodeURIComponent(id)}`; + } + + return null; + } catch (e) { + console.warn("[BACKGROUND] Failed to derive store URL:", e); + return null; + } +} + export async function getJwt() { const data = await chrome.storage.local.get(STORAGE_KEY); return data[STORAGE_KEY] || ""; diff --git a/Extension/ebay-content-script.js b/Extension/ebay-content-script.js index 141b39f..45565a5 100644 --- a/Extension/ebay-content-script.js +++ b/Extension/ebay-content-script.js @@ -72,6 +72,50 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if (message.action === "PARSE_FEEDBACK") { + try { + const feedbackData = extractFeedbackData(); + sendResponse({ ok: true, data: feedbackData }); + } catch (error) { + console.error("[EBAY-CONTENT] PARSE_FEEDBACK error:", error); + sendResponse({ ok: false, error: error.message || "Failed to extract feedback data" }); + } + return true; + } + + if (message.action === "PARSE_ABOUT") { + try { + const aboutData = extractResponseTime(); + sendResponse({ ok: true, data: aboutData }); + } catch (error) { + console.error("[EBAY-CONTENT] PARSE_ABOUT error:", error); + sendResponse({ ok: false, error: error.message || "Failed to extract response time" }); + } + return true; + } + + if (message.action === "PARSE_STORE") { + try { + const storeData = extractFollowers(); + sendResponse({ ok: true, data: storeData }); + } catch (error) { + console.error("[EBAY-CONTENT] PARSE_STORE error:", error); + sendResponse({ ok: false, error: error.message || "Failed to extract followers" }); + } + return true; + } + + if (message.action === "FIND_STORE_LINK") { + try { + const storeUrl = findStoreLink(); + sendResponse({ ok: true, data: { storeUrl } }); + } catch (error) { + console.error("[EBAY-CONTENT] FIND_STORE_LINK error:", error); + sendResponse({ ok: false, error: error.message || "Failed to find store link" }); + } + return true; + } + if (message.action === "PARSE_PRODUCT_LIST") { // async function, need to return promise parseProductList() @@ -1051,6 +1095,325 @@ function parseItemFromLink(itemLink) { return item; } +/** + * Extrahiert Feedback-Daten von ?_tab=feedback Seite + * Hybrid-Parsing-Strategie: Label-basiert → Selectors → Regex + * @returns {object} { feedbackTotal, feedback12mPositive, feedback12mNeutral, feedback12mNegative } + */ +function extractFeedbackData() { + const result = { + feedbackTotal: null, + feedback12mPositive: null, + feedback12mNeutral: null, + feedback12mNegative: null + }; + + try { + const bodyText = document.body?.innerText || ""; + + // Strategy 1: Label-basiert - Suche "Verkäuferbewertungen" / "Gesamtbewertungen" + const totalMatch = bodyText.match(/(?:Verkäuferbewertungen|Gesamtbewertungen)[\s\S]*?\(([\d.,]+)\)/i); + if (totalMatch && totalMatch[1]) { + result.feedbackTotal = parseNumberWithSeparators(totalMatch[1]); + } + + // Strategy 2: Label-basiert - Suche "positive Bewertungen der letzten 12 Monate" + const positiveMatch = bodyText.match(/(\d+[\d.,]*)\s*(?:positive|positive\s*Bewertungen)\s*der\s*letzten\s*12\s*Monate/i); + if (positiveMatch && positiveMatch[1]) { + result.feedback12mPositive = parseNumberWithSeparators(positiveMatch[1]); + } + + // Strategy 3: Selector-Fallback - Bekannte Klassen + try { + const titleEl = document.querySelector('h2.fdbk-detail-list__title, h2[class*="fdbk-detail-list__title"]'); + if (titleEl) { + const titleText = titleEl.textContent || ""; + const match = titleText.match(/\(([\d.,]+)\)/); + if (match && match[1] && !result.feedbackTotal) { + result.feedbackTotal = parseNumberWithSeparators(match[1]); + } + } + + const ratingDetails = document.querySelector('.fdbk-overall-rating__details, [class*="fdbk-overall-rating__details"]'); + if (ratingDetails) { + const links = ratingDetails.querySelectorAll('a'); + for (const link of links) { + const linkText = link.textContent || ""; + const numberMatch = linkText.match(/(\d+[\d.,]*)/); + if (numberMatch && numberMatch[1]) { + const num = parseNumberWithSeparators(numberMatch[1]); + if (linkText.toLowerCase().includes('positiv') && !result.feedback12mPositive) { + result.feedback12mPositive = num; + } else if (linkText.toLowerCase().includes('neutral') && !result.feedback12mNeutral) { + result.feedback12mNeutral = num; + } else if (linkText.toLowerCase().includes('negativ') && !result.feedback12mNegative) { + result.feedback12mNegative = num; + } + } + } + } + } catch (e) { + // Continue to regex fallback + } + + // Strategy 4: Regex-Fallback auf bodyText + if (!result.feedback12mPositive) { + const posMatch = bodyText.match(/(\d+[\d.,]*)\s*-\s*positive\s*Bewertungen\s*der\s*letzten\s*12\s*Monate/i); + if (posMatch && posMatch[1]) { + result.feedback12mPositive = parseNumberWithSeparators(posMatch[1]); + } + } + if (!result.feedback12mNeutral) { + const neuMatch = bodyText.match(/(\d+[\d.,]*)\s*-\s*neutrale\s*Bewertungen\s*der\s*letzten\s*12\s*Monate/i); + if (neuMatch && neuMatch[1]) { + result.feedback12mNeutral = parseNumberWithSeparators(neuMatch[1]); + } + } + if (!result.feedback12mNegative) { + const negMatch = bodyText.match(/(\d+[\d.,]*)\s*-\s*negative\s*Bewertungen\s*der\s*letzten\s*12\s*Monate/i); + if (negMatch && negMatch[1]) { + result.feedback12mNegative = parseNumberWithSeparators(negMatch[1]); + } + } + + } catch (e) { + console.warn("[EBAY-CONTENT] Error extracting feedback data:", e); + } + + return result; +} + +/** + * Extrahiert Response Time von ?_tab=about Seite + * Hybrid-Parsing-Strategie: Label-basiert → Selectors → Regex + * @returns {object} { responseTimeHours } + */ +function extractResponseTime() { + const result = { + responseTimeHours: null + }; + + try { + const bodyText = document.body?.innerText || ""; + + // Strategy 1: Label-basiert - Suche "Antwortzeit:" + const labelMatch = bodyText.match(/Antwortzeit[:\s]+(?:innerhalb\s+)?(\d+)\s*(?:Stunde|Stunden)/i); + if (labelMatch && labelMatch[1]) { + result.responseTimeHours = parseInt(labelMatch[1], 10); + if (!isNaN(result.responseTimeHours) && result.responseTimeHours > 0) { + return result; + } + } + + // Strategy 2: Selector-Fallback + try { + const spans = document.querySelectorAll('span.str-text-span, span[class*="str-text-span"]'); + for (const span of spans) { + const spanText = span.textContent || ""; + if (spanText.includes('Antwortzeit')) { + const nextSpan = span.nextElementSibling; + if (nextSpan) { + const timeText = nextSpan.textContent || ""; + const match = timeText.match(/(?:innerhalb\s+)?(\d+)\s*(?:Stunde|Stunden)/i); + if (match && match[1]) { + const hours = parseInt(match[1], 10); + if (!isNaN(hours) && hours > 0) { + result.responseTimeHours = hours; + return result; + } + } + } + } + } + } catch (e) { + // Continue to regex fallback + } + + // Strategy 3: Regex-Fallback + const regexMatch = bodyText.match(/innerhalb\s+(\d+)\s*(?:Stunde|Stunden)/i); + if (regexMatch && regexMatch[1]) { + const hours = parseInt(regexMatch[1], 10); + if (!isNaN(hours) && hours > 0) { + result.responseTimeHours = hours; + } + } + + } catch (e) { + console.warn("[EBAY-CONTENT] Error extracting response time:", e); + } + + return result; +} + +/** + * Extrahiert Follower von Store-Seite + * Hybrid-Parsing-Strategie: Label-basiert → Selectors → Regex + * @returns {object} { followers } + */ +function extractFollowers() { + const result = { + followers: null + }; + + try { + const bodyText = document.body?.innerText || ""; + + // Strategy 1: Label-basiert - Suche "Follower" + const followerMatch = bodyText.match(/(\d+[\d.,]*)\s*Follower/i); + if (followerMatch && followerMatch[1]) { + result.followers = parseNumberWithSeparators(followerMatch[1]); + if (result.followers !== null) { + return result; + } + } + + // Strategy 2: Selector-Fallback + try { + const statsContainer = document.querySelector('.str-seller-card__store-stats-content, [class*="str-seller-card__store-stats-content"]'); + if (statsContainer) { + const divs = statsContainer.querySelectorAll('div'); + for (const div of divs) { + const divText = div.textContent || ""; + if (divText.includes('Follower')) { + const boldSpan = div.querySelector('span.BOLD, span[class*="BOLD"], .str-text-span.BOLD'); + if (boldSpan) { + const followerText = boldSpan.textContent || ""; + result.followers = parseNumberWithSeparators(followerText); + if (result.followers !== null) { + return result; + } + } + // Fallback: regex on div text + const match = divText.match(/(\d+[\d.,]*)\s*Follower/i); + if (match && match[1]) { + result.followers = parseNumberWithSeparators(match[1]); + if (result.followers !== null) { + return result; + } + } + } + } + } + } catch (e) { + // Continue to regex fallback + } + + // Strategy 3: Regex-Fallback auf bodyText (wenn noch nicht gefunden) + if (result.followers === null) { + const regexMatch = bodyText.match(/(\d+[\d.,]*)\s*Follower/i); + if (regexMatch && regexMatch[1]) { + result.followers = parseNumberWithSeparators(regexMatch[1]); + } + } + + } catch (e) { + console.warn("[EBAY-CONTENT] Error extracting followers:", e); + } + + return result; +} + +/** + * Findet Store-URL auf der aktuellen Seite + * Sucht nach Links mit Text "Store" / "Shop" oder href enthält "/str/" + * @returns {string|null} Store URL oder null + */ +function findStoreLink() { + try { + // Strategy 1: Suche Links mit Text "Store" / "Shop" + const allLinks = document.querySelectorAll('a[href]'); + for (const link of allLinks) { + const linkText = (link.textContent || "").toLowerCase().trim(); + const href = link.href || link.getAttribute('href') || ""; + + if (href.includes('/str/')) { + // Absoluter oder relativer URL + try { + const url = new URL(href, window.location.origin); + if (url.hostname.includes('ebay.')) { + return url.href; + } + } catch (e) { + // Invalid URL, continue + } + } + + // Prüfe Link-Text + if ((linkText.includes('store') || linkText.includes('shop')) && href.includes('/str/')) { + try { + const url = new URL(href, window.location.origin); + if (url.hostname.includes('ebay.')) { + return url.href; + } + } catch (e) { + // Invalid URL, continue + } + } + } + + // Strategy 2: Suche nach href mit /str/ Pattern + for (const link of allLinks) { + const href = link.href || link.getAttribute('href') || ""; + if (href.includes('/str/')) { + try { + const url = new URL(href, window.location.origin); + if (url.hostname.includes('ebay.')) { + return url.href; + } + } catch (e) { + // Invalid URL, continue + } + } + } + + return null; + } catch (e) { + console.warn("[EBAY-CONTENT] Error finding store link:", e); + return null; + } +} + +/** + * Parst eine Zahl mit Tausendertrennern und "Mio." Unterstützung + * @param {string} text - Zahl-String z.B. "884.318" oder "2,4 Mio." + * @returns {number|null} Geparste Zahl oder null + */ +function parseNumberWithSeparators(text) { + if (!text || typeof text !== 'string') return null; + + try { + // Entferne Leerzeichen + let normalized = text.trim().replace(/\s/g, ''); + + // Prüfe auf "Mio." / "Millionen" + const mioMatch = normalized.match(/([\d,]+)\s*[,.]?\s*(?:Mio|Millionen)/i); + if (mioMatch && mioMatch[1]) { + const numStr = mioMatch[1].replace(/,/g, '.'); + const num = parseFloat(numStr); + if (!isNaN(num)) { + return Math.round(num * 1000000); + } + } + + // Entferne Tausendertrenner (Punkte und Kommas) + normalized = normalized.replace(/\./g, ''); // Entferne Punkte (Tausendertrenner) + normalized = normalized.replace(/,/g, '.'); // Ersetze Kommas durch Punkte (Dezimaltrenner) + + // Nur Digits und einen Dezimalpunkt behalten + normalized = normalized.replace(/[^\d.]/g, ''); + + // Parse zu Integer (für Follower/Feedback sind nur ganze Zahlen relevant) + const num = parseInt(normalized, 10); + if (!isNaN(num) && num >= 0) { + return num; + } + + return null; + } catch (e) { + return null; + } +} + /** * Parst Preis-String in Zahl und Währung * @param {string} priceText - Preis-String z.B. "EUR 12,99" oder "$15.50" diff --git a/Server/backend/package-lock.json b/Server/backend/package-lock.json new file mode 100644 index 0000000..481738e --- /dev/null +++ b/Server/backend/package-lock.json @@ -0,0 +1,785 @@ +{ + "name": "eship-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "eship-backend", + "version": "0.1.0", + "dependencies": { + "dotenv": "^16.3.1", + "express": "^4.18.2", + "node-appwrite": "^14.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-appwrite": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-14.2.0.tgz", + "integrity": "sha512-sPPA+JzdBJRS+lM6azX85y3/6iyKQYlHcXCbjMuWLROh6IiU9EfXRW3XSUTa5HDoBrlo8ve+AnVA6BIjQfUs1g==", + "dependencies": { + "node-fetch-native-with-agent": "1.7.2" + } + }, + "node_modules/node-fetch-native-with-agent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-fetch-native-with-agent/-/node-fetch-native-with-agent-1.7.2.tgz", + "integrity": "sha512-5MaOOCuJEvcckoz7/tjdx1M6OusOY6Xc5f459IaruGStWnKzlI1qpNgaAwmn4LmFYcsSlj+jBMk84wmmRxfk5g==" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/Server/src/components/onboarding/OnboardingGate.jsx b/Server/src/components/onboarding/OnboardingGate.jsx index f2f923e..c6d53ad 100644 --- a/Server/src/components/onboarding/OnboardingGate.jsx +++ b/Server/src/components/onboarding/OnboardingGate.jsx @@ -8,8 +8,8 @@ import ColourfulText from "@/components/ui/colourful-text"; import { PlaceholdersAndVanishInput } from "@/components/ui/placeholders-and-vanish-input"; import { MultiStepLoader } from "@/components/ui/multi-step-loader"; import { IPhoneNotification } from "@/components/ui/iphone-notification"; -import { parseEbayAccount } from "@/services/ebayParserService"; -import { createManagedAccount, fetchManagedAccounts } from "@/services/accountsService"; +import { parseEbayAccount, parseViaExtensionExtended } from "@/services/ebayParserService"; +import { createManagedAccount, fetchManagedAccounts, determineRefreshStatus } from "@/services/accountsService"; import { account, databases, databaseId, usersCollectionId } from "@/lib/appwrite"; import { DottedGlowBackground } from "@/components/ui/dotted-glow-background"; @@ -618,13 +618,20 @@ export const OnboardingGate = ({ userName, onStart, loading, error, initialPhase setPhase("loading"); try { - // Parse eBay account + // Parse eBay account (erweitert mit Feedback, About, Store) // #region agent log - fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:449',message:'handleAccountSubmit: before parseEbayAccount',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{}); + fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:449',message:'handleAccountSubmit: before parseViaExtensionExtended',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{}); // #endregion - const accountData = await parseEbayAccount(url); + let accountData; + try { + accountData = await parseViaExtensionExtended(url); + } catch (extendedError) { + // Fallback zu normalem Parsing wenn erweiterte Funktion fehlschlägt + console.warn("Extended parsing failed, falling back to normal parsing:", extendedError); + accountData = await parseEbayAccount(url); + } // #region agent log - fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:452',message:'handleAccountSubmit: parseEbayAccount success',data:{url,hasAccountData:!!accountData,market:accountData?.market,sellerId:accountData?.sellerId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{}); + fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:452',message:'handleAccountSubmit: parseViaExtensionExtended success',data:{url,hasAccountData:!!accountData,market:accountData?.market,sellerId:accountData?.sellerId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H2'})}).catch(()=>{}); // #endregion // Validate that sellerId was extracted successfully @@ -676,6 +683,21 @@ export const OnboardingGate = ({ userName, onStart, loading, error, initialPhase account_url: url, account_status: accountData.status || "active", account_sells: accountData.stats?.itemsSold ?? null, // Setze account_sells wenn verfügbar + // Neue erweiterte Felder + account_response_time_hours: accountData.responseTimeHours ?? null, + account_followers: accountData.followers ?? null, + account_feedback_total: accountData.feedbackTotal ?? null, + account_feedback_12m_positive: accountData.feedback12mPositive ?? null, + account_feedback_12m_neutral: accountData.feedback12mNeutral ?? null, + account_feedback_12m_negative: accountData.feedback12mNegative ?? null, + account_last_refresh_at: new Date().toISOString(), + account_last_refresh_status: accountData.partialResults ? + determineRefreshStatus(accountData.partialResults, { + followers: accountData.followers, + feedbackTotal: accountData.feedbackTotal, + feedback12mPositive: accountData.feedback12mPositive, + responseTimeHours: accountData.responseTimeHours + }) : null, }); // #region agent log fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:494',message:'handleAccountSubmit: createManagedAccount success',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'H3'})}).catch(()=>{}); diff --git a/Server/src/components/ui/GradientText.css b/Server/src/components/ui/GradientText.css new file mode 100644 index 0000000..ceab378 --- /dev/null +++ b/Server/src/components/ui/GradientText.css @@ -0,0 +1,51 @@ +.animated-gradient-text { + position: relative; + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + border-radius: 1.25rem; + font-weight: 500; + backdrop-filter: blur(10px); + transition: box-shadow 0.5s ease-out; + overflow: hidden; +} + +.animated-gradient-text.with-border { + padding: 0.35rem 0.75rem; +} + +.gradient-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: inherit; + z-index: 0; + pointer-events: none; +} + +.gradient-overlay::before { + content: ''; + position: absolute; + left: 0; + top: 0; + border-radius: inherit; + width: calc(100% - 2px); + height: calc(100% - 2px); + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + background-color: #060010; + z-index: -1; +} + +.text-content { + display: inline-block; + position: relative; + z-index: 2; + background-clip: text; + -webkit-background-clip: text; + color: transparent; +} diff --git a/Server/src/components/ui/GradientText.jsx b/Server/src/components/ui/GradientText.jsx new file mode 100644 index 0000000..e66be6e --- /dev/null +++ b/Server/src/components/ui/GradientText.jsx @@ -0,0 +1,100 @@ +import { useState, useCallback, useEffect, useRef } from 'react'; +import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react'; +import './GradientText.css'; + +export default function GradientText({ + children, + className = '', + colors = ['#5227FF', '#FF9FFC', '#B19EEF'], + animationSpeed = 8, + showBorder = false, + direction = 'horizontal', + pauseOnHover = false, + yoyo = true, + style = {} +}) { + const [isPaused, setIsPaused] = useState(false); + const progress = useMotionValue(0); + const elapsedRef = useRef(0); + const lastTimeRef = useRef(null); + + const animationDuration = animationSpeed * 1000; + + useAnimationFrame(time => { + if (isPaused) { + lastTimeRef.current = null; + return; + } + + if (lastTimeRef.current === null) { + lastTimeRef.current = time; + return; + } + + const deltaTime = time - lastTimeRef.current; + lastTimeRef.current = time; + elapsedRef.current += deltaTime; + + if (yoyo) { + const fullCycle = animationDuration * 2; + const cycleTime = elapsedRef.current % fullCycle; + + if (cycleTime < animationDuration) { + progress.set((cycleTime / animationDuration) * 100); + } else { + progress.set(100 - ((cycleTime - animationDuration) / animationDuration) * 100); + } + } else { + // Continuously increase position for seamless looping + progress.set((elapsedRef.current / animationDuration) * 100); + } + }); + + useEffect(() => { + elapsedRef.current = 0; + progress.set(0); + }, [animationSpeed, progress, yoyo]); + + const backgroundPosition = useTransform(progress, p => { + if (direction === 'horizontal') { + return `${p}% 50%`; + } else if (direction === 'vertical') { + return `50% ${p}%`; + } else { + // For diagonal, move only horizontally to avoid interference patterns + return `${p}% 50%`; + } + }); + + const handleMouseEnter = useCallback(() => { + if (pauseOnHover) setIsPaused(true); + }, [pauseOnHover]); + + const handleMouseLeave = useCallback(() => { + if (pauseOnHover) setIsPaused(false); + }, [pauseOnHover]); + + const gradientAngle = + direction === 'horizontal' ? 'to right' : direction === 'vertical' ? 'to bottom' : 'to bottom right'; + // Duplicate first color at the end for seamless looping + const gradientColors = [...colors, colors[0]].join(', '); + + const gradientStyle = { + backgroundImage: `linear-gradient(${gradientAngle}, ${gradientColors})`, + backgroundSize: direction === 'horizontal' ? '300% 100%' : direction === 'vertical' ? '100% 300%' : '300% 300%', + backgroundRepeat: 'repeat' + }; + + return ( + + {showBorder && } + + {children} + + + ); +} diff --git a/Server/src/pages/AccountsPage.jsx b/Server/src/pages/AccountsPage.jsx index 313c5f0..b377025 100644 --- a/Server/src/pages/AccountsPage.jsx +++ b/Server/src/pages/AccountsPage.jsx @@ -5,8 +5,6 @@ import { IconX, IconRefresh, IconChevronDown, - IconChartBar, - IconSettings, } from "@tabler/icons-react"; import { motion, AnimatePresence } from "motion/react"; import { cn } from "../lib/utils"; @@ -16,10 +14,12 @@ import { getActiveAccountId, getAccountDisplayName, } from "../services/accountService"; -import { fetchManagedAccounts, createManagedAccount, updateManagedAccount } from "../services/accountsService"; -import { getAuthUser } from "../lib/appwrite"; -import { parseEbayAccount } from "../services/ebayParserService"; +import { fetchManagedAccounts, createManagedAccount, updateManagedAccount, determineRefreshStatus, calculateDataFreshness, getLastSuccessfulAccountMetric } from "../services/accountsService"; +import { upsertAccountMetric, fetchAccountMetricsForMonth } from "../services/accountMetricsService"; +import { getAuthUser, databases, databaseId } from "../lib/appwrite"; +import { parseEbayAccount, parseViaExtensionExtended } from "../services/ebayParserService"; import { BentoGrid, BentoGridItem } from "../components/ui/bento-grid"; +import GradientText from "../components/ui/GradientText"; function AccountNameCard({ name, @@ -96,18 +96,28 @@ function AccountNameCard({ href={url} target="_blank" rel="noopener noreferrer" - className="max-w-full shrink-0 whitespace-nowrap font-bold leading-tight text-[var(--text)] hover:underline" - style={{ fontSize: `${fontSize}px` }} + className="max-w-full shrink-0 whitespace-nowrap hover:underline" > - {name} + + {name} + ) : ( - {name} - + )} {platformAccountId != null && platformAccountId !== "" && ( @@ -232,6 +242,374 @@ function RangCard({ rank, className }) { ); } +function RefreshActivityCard({ monthMetrics = new Map(), className }) { + // monthMetrics: Map von date (yyyy-mm-dd) -> metric document + + const today = new Date(); + const currentMonth = today.getMonth(); + const currentYear = today.getFullYear(); + const currentDate = today.getDate(); + + // Erstelle Kalenderstruktur für aktuellen Monat + const getMonthCalendar = () => { + const firstDay = new Date(currentYear, currentMonth, 1); + const lastDay = new Date(currentYear, currentMonth + 1, 0); + const daysInMonth = lastDay.getDate(); + const startDayOfWeek = firstDay.getDay(); // 0 = Sonntag, 1 = Montag, etc. + + // Wochen beginnen mit Montag (1) statt Sonntag (0) + const adjustedStartDay = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1; + + const calendar = []; + let currentWeek = []; + + // Leere Felder für Tage vor Monatsbeginn + for (let i = 0; i < adjustedStartDay; i++) { + currentWeek.push(null); + } + + // Tage des Monats + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(currentYear, currentMonth, day); + currentWeek.push(date); + + if (currentWeek.length === 7) { + calendar.push(currentWeek); + currentWeek = []; + } + } + + // Leere Felder für restliche Woche + if (currentWeek.length > 0) { + while (currentWeek.length < 7) { + currentWeek.push(null); + } + calendar.push(currentWeek); + } + + return calendar; + }; + + const getDayData = (date) => { + if (!date) return { refreshStatus: null, salesBucket: null }; + + const dateStr = date.toISOString().split('T')[0]; + const metric = monthMetrics.get(dateStr); + + if (!metric) { + return { + refreshStatus: 'not-refreshed', + salesBucket: null + }; + } + + // Bestimme refreshStatus aus metric + let refreshStatus = 'not-refreshed'; + if (metric.account_metrics_refresh_status === 'failed') { + refreshStatus = 'failed'; + } else if (metric.account_metrics_refreshed === true && metric.account_metrics_refresh_status === 'success') { + refreshStatus = 'refreshed'; + } + + return { + refreshStatus: refreshStatus, + salesBucket: metric.account_metrics_sales_bucket || null + }; + }; + + const getStatusColor = (status, isToday) => { + if (isToday) { + // Aktueller Tag: dezente Umrandung + switch (status) { + case 'refreshed': + return 'bg-green-500/30 border-2 border-green-600 dark:bg-green-500/20 dark:border-green-500'; + case 'failed': + return 'bg-red-500/20 border-2 border-red-600 dark:bg-red-500/10 dark:border-red-500'; + default: + return 'bg-neutral-100 border-2 border-neutral-400 dark:bg-neutral-800 dark:border-neutral-500'; + } + } + + switch (status) { + case 'refreshed': + return 'bg-green-500/30 border border-green-500/50 dark:bg-green-500/20 dark:border-green-500/40'; + case 'failed': + return 'bg-red-500/20 border border-red-500/30 dark:bg-red-500/10 dark:border-red-500/20'; + default: + return 'bg-neutral-100 border border-neutral-200 dark:bg-neutral-800 dark:border-neutral-700'; + } + }; + + const calendar = getMonthCalendar(); + const isTodayDate = (date) => { + if (!date) return false; + return date.getDate() === currentDate && + date.getMonth() === currentMonth && + date.getFullYear() === currentYear; + }; + + return ( +
+
+ {/* Titel oben links */} +
+
Refresh Activity
+
+ + {/* Wochenraster: 7 Spalten x 5 Zeilen */} +
+ {calendar.map((week, weekIndex) => ( + + {week.map((date, dayIndex) => { + if (!date) { + return ( +
+ ); + } + + const dayData = getDayData(date); + const isToday = isTodayDate(date); + const dayNumber = date.getDate(); + + return ( +
+ {/* Tag-Nummer (klein, oben links) */} +
+ {dayNumber} +
+ + {/* Sales-Bucket (zentriert) */} + {dayData.salesBucket && ( +
+ {dayData.salesBucket} +
+ )} +
+ ); + })} + + ))} +
+
+
+ ); +} + +function AccountRefreshCard({ + onRefresh, + isRefreshing, + lastRefreshDate, + streak, + dataFreshness = 'Aging', + className +}) { + const getLastRefreshText = () => { + if (!lastRefreshDate) return "Not refreshed today"; + + const today = new Date(); + const refreshDate = new Date(lastRefreshDate); + const diffTime = today - refreshDate; + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return "Last refresh: Today"; + if (diffDays === 1) return "Last refresh: Yesterday"; + if (diffDays < 7) return `Last refresh: ${diffDays} days ago`; + return `Last refresh: ${refreshDate.toLocaleDateString('de-DE')}`; + }; + + const getFreshnessLabel = () => { + switch (dataFreshness) { + case 'Fresh': return 'Fresh'; + case 'Outdated': return 'Outdated'; + default: return 'Aging'; + } + }; + + return ( +
+ {/* Grid: 5 Zeilen x 5 Spalten */} +
+ {/* Zeile 1: Kontext & Bedeutung */} + {/* Bereich A (Zeile 1, Spalten 1-5) */} +
+
Account Refresh
+
manual
+
+ + {/* Zeile 2-3: Zentrale Aktion (Ritualkern) */} + {/* Bereich B (Zeilen 2-3, Spalten 1-5) */} +
+ +
+ + {/* Zeile 4: Tages- & Streak-Status */} + {/* Bereich C (Zeile 4, Spalten 1-5) */} +
+ {getLastRefreshText()} + {streak != null && streak > 0 && ( + Streak: {streak} {streak === 1 ? 'day' : 'days'} + )} +
+ + {/* Zeile 5: Datenqualitäts-Hinweis */} + {/* Bereich D (Zeile 5, Spalten 1-5) */} +
+
+ Data freshness: {getFreshnessLabel()} +
+
+
+
+ ); +} + +function SalesCard({ sales, follower, responseTime, positiveReviews, neutralReviews, negativeReviews, totalReviews, className }) { + // Berechne Anteile für Balkendiagramm + // WICHTIG: null = nicht verfügbar, 0 = echter Wert + const total = (positiveReviews ?? 0) + (neutralReviews ?? 0) + (negativeReviews ?? 0); + const positivePercent = total > 0 ? ((positiveReviews ?? 0) / total) * 100 : 0; + const neutralPercent = total > 0 ? ((neutralReviews ?? 0) / total) * 100 : 0; + const negativePercent = total > 0 ? ((negativeReviews ?? 0) / total) * 100 : 0; + + return ( +
+ {/* Grid: 5 Zeilen x 10 Spalten */} +
+ {/* Zeile 1-2: Hero-Zone (40%) */} + {/* Block A: Sales (Spalten 1-4, Zeilen 1-2) */} +
+
+ {sales != null ? new Intl.NumberFormat("de-DE").format(sales) : "–"} +
+
Sales (gesamt)
+
+ + {/* Block B: Follower (Spalten 5-7, Zeilen 1-2) */} +
+
+ {follower != null ? new Intl.NumberFormat("de-DE").format(follower) : "–"} +
+
Follower
+
+ + {/* Block C: Antwortzeit (Spalten 8-10, Zeile 1) */} +
+
+ Antwortzeit {responseTime ?? "—"} +
+
+ + {/* Zeile 2, Spalten 8-10: leer */} +
+ + {/* Zeile 3-4: Bewertungen (40%) */} + {/* Block D: Bewertungszusammenfassung (Spalten 1-10, Zeilen 3-4) */} +
+
Bewertungen der letzten 12 Monate
+ + {/* Balkendiagramm */} +
+ {positivePercent > 0 && ( +
+ {positivePercent > 15 && ( + + {positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"} + + )} +
+ )} + {neutralPercent > 0 && ( +
+ {neutralPercent > 15 && ( + + {neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"} + + )} +
+ )} + {negativePercent > 0 && ( +
+ {negativePercent > 15 && ( + + {negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"} + + )} +
+ )} +
+ + {/* Zahlen unter dem Balken (falls Platz) */} + {positivePercent <= 15 && neutralPercent <= 15 && negativePercent <= 15 && ( +
+ Positiv: {positiveReviews != null ? new Intl.NumberFormat("de-DE").format(positiveReviews) : "—"} + Neutral: {neutralReviews != null ? new Intl.NumberFormat("de-DE").format(neutralReviews) : "—"} + Negativ: {negativeReviews != null ? new Intl.NumberFormat("de-DE").format(negativeReviews) : "—"} +
+ )} +
+ + {/* Zeile 5: Meta-Informationen (20%) */} + {/* Block E: Gesamtbewertungen (Spalten 1-10, Zeile 5) */} +
+
+ Gesamtbewertungen: {totalReviews != null ? new Intl.NumberFormat("de-DE").format(totalReviews) : "–"} +
+
+
+
+ ); +} + export const AccountsPage = () => { const { navigate } = useHashRoute(); const [accounts, setAccounts] = useState([]); @@ -253,6 +631,9 @@ export const AccountsPage = () => { // Nur ein Account wird angezeigt; Wechsel über Dropdown const [displayedAccountId, setDisplayedAccountId] = useState(null); + // Monats-Metriken für Kalender (Map: date -> metric) + const [monthMetrics, setMonthMetrics] = useState(new Map()); + // Form-Felder (nur noch URL) const [formData, setFormData] = useState({ account_url: "", @@ -278,6 +659,15 @@ export const AccountsPage = () => { } }, [accounts]); + // Lade Monats-Metriken wenn displayedAccountId sich ändert + useEffect(() => { + if (displayedAccountId) { + loadMonthMetrics(displayedAccountId); + } else { + setMonthMetrics(new Map()); + } + }, [displayedAccountId]); + async function loadAccounts() { setLoading(true); try { @@ -297,6 +687,25 @@ export const AccountsPage = () => { } } + async function loadMonthMetrics(accountId) { + if (!accountId) { + setMonthMetrics(new Map()); + return; + } + + try { + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth() + 1; // 1-12 + + const metrics = await fetchAccountMetricsForMonth(accountId, year, month); + setMonthMetrics(metrics); + } catch (e) { + console.error("Fehler beim Laden der Monats-Metriken:", e); + setMonthMetrics(new Map()); + } + } + const handleDisplayedAccountChange = (accountId) => { setDisplayedAccountId(accountId); setActiveAccountId(accountId); @@ -315,8 +724,16 @@ export const AccountsPage = () => { setRefreshingAccountId(accountId); try { - // URL erneut parsen - const parsedData = await parseEbayAccount(accountUrl); + // URL erweitert parsen (mit Feedback, About, Store) + const parsedData = await parseViaExtensionExtended(accountUrl); + + // Refresh-Status bestimmen basierend auf Partial Results + const refreshStatus = determineRefreshStatus(parsedData.partialResults, { + followers: parsedData.followers, + feedbackTotal: parsedData.feedbackTotal, + feedback12mPositive: parsedData.feedback12mPositive, + responseTimeHours: parsedData.responseTimeHours + }); // Account in DB aktualisieren // WICHTIG: Nur Felder setzen, die nicht leer sind und sich geändert haben @@ -337,6 +754,18 @@ export const AccountsPage = () => { updatePayload.account_shop_name = parsedData.shopName || null; updatePayload.account_sells = parsedData.stats?.itemsSold ?? null; + // Neue erweiterte Felder + updatePayload.account_response_time_hours = parsedData.responseTimeHours ?? null; + updatePayload.account_followers = parsedData.followers ?? null; + updatePayload.account_feedback_total = parsedData.feedbackTotal ?? null; + updatePayload.account_feedback_12m_positive = parsedData.feedback12mPositive ?? null; + updatePayload.account_feedback_12m_neutral = parsedData.feedback12mNeutral ?? null; + updatePayload.account_feedback_12m_negative = parsedData.feedback12mNegative ?? null; + + // Refresh-Metadaten + updatePayload.account_last_refresh_at = new Date().toISOString(); + updatePayload.account_last_refresh_status = refreshStatus; + // account_status wird weggelassen (wie beim Erstellen) // Grund: Schema-Konflikt - Enum-Feld akzeptiert weder String noch Array im Update // TODO: Schema in Appwrite prüfen und korrigieren, dann account_status wieder hinzufügen @@ -344,7 +773,76 @@ export const AccountsPage = () => { // Setze account_updated_at auf aktuelle Zeit updatePayload.account_updated_at = new Date().toISOString(); - await updateManagedAccount(accountId, updatePayload); + const updatedAccount = await updateManagedAccount(accountId, updatePayload); + + // Berechne Sales-Differenz und speichere in account_metrics + try { + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; // yyyy-mm-dd + + // Lade letzte erfolgreiche Metrik für Sales-Differenz-Berechnung + const lastMetric = await getLastSuccessfulAccountMetric(accountId); + const currentSalesTotal = updatedAccount.account_sells; + + // Berechne Sales-Differenz: + // Da account_metrics_last_sales_total nicht erlaubt ist, verwenden wir einen Workaround: + // - account_metrics_sales_count speichert den absoluten account_sells Wert + // - Beim nächsten Refresh berechnen wir die Differenz: currentSalesTotal - lastMetric.sales_count + // - Die Differenz wird in account_metrics_sales_bucket gespeichert (als Bucket-String) + // - Für die Anzeige im Kalender verwenden wir den Bucket-String + + let salesDifference = null; + if (lastMetric && lastMetric.account_metrics_sales_count !== null && currentSalesTotal !== null) { + // Letzte Metrik existiert: Berechne Differenz + // lastMetric.account_metrics_sales_count ist der absolute Wert vom letzten Refresh + const lastAbsoluteValue = lastMetric.account_metrics_sales_count; + salesDifference = currentSalesTotal - lastAbsoluteValue; + + // Stelle sicher, dass Differenz nicht negativ ist (falls account_sells zurückgesetzt wurde) + if (salesDifference < 0) { + salesDifference = null; + } + } + + // Speichere absoluten Wert in sales_count (für nächsten Refresh) + // Die Differenz wird in sales_bucket gespeichert (berechnet via calculateSalesBucket) + const salesCountToStore = currentSalesTotal; + + // Upsert account_metrics für heute + // WICHTIG: Da account_metrics_last_sales_total nicht erlaubt ist, speichern wir: + // - account_metrics_sales_count: absoluten account_sells Wert (für nächsten Refresh) + // - account_metrics_sales_bucket: Bucket basierend auf der Differenz (wenn berechenbar) + // + // Beim nächsten Refresh: Differenz = currentSalesTotal - lastMetric.sales_count + + // Berechne Bucket aus Differenz (falls berechenbar) + const { calculateSalesBucket } = await import("../services/accountMetricsService"); + const bucket = salesDifference !== null ? calculateSalesBucket(salesDifference) : null; + + // Erstelle/Update Metrik mit absolutem Wert + const metricDoc = await upsertAccountMetric(accountId, todayStr, { + refreshed: true, + refreshStatus: refreshStatus === "success" ? "success" : "failed", + refreshedAt: new Date().toISOString(), + salesCount: currentSalesTotal // Absoluter Wert für nächsten Refresh + }); + + // Update sales_bucket separat (da upsertAccountMetric Bucket aus salesCount berechnet) + if (bucket !== null && metricDoc) { + await databases.updateDocument( + databaseId, + "account_metrics", + metricDoc.$id, + { account_metrics_sales_bucket: bucket } + ); + } + + // Lade Monats-Metriken neu + await loadMonthMetrics(accountId); + } catch (metricsError) { + // Nicht kritisch, nur loggen + console.warn("Fehler beim Erstellen der Account-Metrik:", metricsError); + } // Accounts-Liste neu laden (in-place Update) await loadAccounts(); @@ -362,6 +860,25 @@ export const AccountsPage = () => { setRefreshToast({ show: true, message: errorMessage, type: "error" }); setTimeout(() => setRefreshToast({ show: false, message: "", type: "success" }), 3000); + + // Auch bei Fehler: Metrik für heute speichern (mit failed Status) + try { + const today = new Date(); + const todayStr = today.toISOString().split('T')[0]; // yyyy-mm-dd + + await upsertAccountMetric(accountId, todayStr, { + refreshed: false, + refreshStatus: "failed", + refreshedAt: null, + salesCount: null + }); + + // Lade Monats-Metriken neu + await loadMonthMetrics(accountId); + } catch (metricsError) { + // Nicht kritisch, nur loggen + console.warn("Fehler beim Erstellen der Account-Metrik (failed):", metricsError); + } } finally { setRefreshingAccountId(null); } @@ -473,10 +990,6 @@ export const AccountsPage = () => { const displayedAccount = accounts.find((a) => (a.$id || a.id) === displayedAccountId) ?? accounts[0] ?? null; - const BentoHeader = () => ( -
- ); - return (
@@ -557,33 +1070,26 @@ export const AccountsPage = () => { className="md:col-span-1" /> - } - icon={} + - - -
- } - header={} - icon={} + handleRefreshAccount(account)} + isRefreshing={isRefreshing} + lastRefreshDate={account.account_last_refresh_at || null} + streak={account.account_refresh_streak || null} + dataFreshness={calculateDataFreshness(account.account_last_refresh_at) || 'Aging'} + /> + ); diff --git a/Server/src/services/accountMetricsService.js b/Server/src/services/accountMetricsService.js new file mode 100644 index 0000000..a5bb428 --- /dev/null +++ b/Server/src/services/accountMetricsService.js @@ -0,0 +1,219 @@ +/** + * Account Metrics Service + * CRUD-Operationen für account_metrics Collection + * Verwaltet tägliche Account-Metriken (Refresh-Status, Sales-Daten) + */ + +import { databases, databaseId } from "../lib/appwrite"; +import { ID, Query } from "appwrite"; + +const accountMetricsCollectionId = "account_metrics"; + +/** + * Berechnet Sales-Bucket aus Sales-Count + * @param {number|null} salesCount - Sales-Count oder null + * @returns {string|null} Bucket ("1+", "5+", "10+", "50+") oder null + */ +export function calculateSalesBucket(salesCount) { + if (salesCount === null || salesCount === undefined || salesCount === 0) { + return null; + } + + if (salesCount >= 1 && salesCount <= 4) { + return "1+"; + } + if (salesCount >= 5 && salesCount <= 9) { + return "5+"; + } + if (salesCount >= 10 && salesCount <= 49) { + return "10+"; + } + if (salesCount >= 50) { + return "50+"; + } + + return null; +} + +/** + * Formatiert Datum zu "yyyy-mm-dd" String + * @param {Date|string} date - Date-Objekt oder ISO-String + * @returns {string} Formatierte Datum-String + */ +function formatDate(date) { + if (typeof date === 'string') { + // Wenn bereits String, stelle sicher dass es yyyy-mm-dd Format ist + const d = new Date(date); + return d.toISOString().split('T')[0]; + } + return date.toISOString().split('T')[0]; +} + +/** + * Formatiert Datum zu "yyyy-mm" String + * @param {Date|string} date - Date-Objekt oder ISO-String + * @returns {string} Formatierte Monat-String + */ +function formatMonth(date) { + const dateStr = formatDate(date); + return dateStr.substring(0, 7); // "yyyy-mm" +} + +/** + * Erstellt oder aktualisiert eine Account-Metrik für einen Tag + * @param {string} accountId - ID des Accounts + * @param {string|Date} date - Datum (yyyy-mm-dd String oder Date-Objekt) + * @param {Object} data - Metrik-Daten + * @param {boolean} data.refreshed - Refresh-Status + * @param {string} data.refreshStatus - "success" | "failed" + * @param {number|null} [data.salesCount] - Sales-Differenz + * @returns {Promise} Erstelltes oder aktualisiertes Metrik-Dokument + */ +export async function upsertAccountMetric(accountId, date, data) { + if (!accountId) { + throw new Error("accountId ist erforderlich"); + } + + const dateStr = formatDate(date); + const monthStr = formatMonth(date); + + // Berechne Sales-Bucket aus salesCount + const salesBucket = calculateSalesBucket(data.salesCount); + + // Prüfe ob Metrik für diesen Tag bereits existiert + try { + const existing = await databases.listDocuments( + databaseId, + accountMetricsCollectionId, + [ + Query.equal("account_metrics_account_id", accountId), + Query.equal("account_metrics_date", dateStr), + Query.limit(1) + ] + ); + + const payload = { + account_metrics_account_id: accountId, + account_metrics_date: dateStr, + account_metrics_month: monthStr, + account_metrics_refreshed: data.refreshed, + account_metrics_refresh_status: data.refreshStatus, + account_metrics_refreshed_at: data.refreshedAt || null, + account_metrics_sales_count: data.salesCount ?? null, + account_metrics_sales_bucket: salesBucket + }; + + // Speichere lastSalesTotal in rawSnapshot (falls vorhanden) + // Da rawSnapshot nicht in der erlaubten Liste ist, speichern wir es nicht + // Stattdessen: lastSalesTotal wird beim nächsten Refresh aus dem Account-Dokument geholt + + let result; + if (existing.documents.length > 0) { + // Update existing document + const existingDoc = existing.documents[0]; + result = await databases.updateDocument( + databaseId, + accountMetricsCollectionId, + existingDoc.$id, + payload + ); + } else { + // Create new document + result = await databases.createDocument( + databaseId, + accountMetricsCollectionId, + ID.unique(), + payload + ); + } + return result; + } catch (e) { + console.error("Fehler beim Upsert der Account-Metrik:", e); + throw e; + } +} + +/** + * Lädt alle Account-Metriken für einen Monat + * @param {string} accountId - ID des Accounts + * @param {number} year - Jahr (z.B. 2026) + * @param {number} month - Monat (1-12) + * @returns {Promise>} Map von date (yyyy-mm-dd) -> Metrik-Dokument + */ +export async function fetchAccountMetricsForMonth(accountId, year, month) { + if (!accountId) { + throw new Error("accountId ist erforderlich"); + } + + // Formatiere Monat zu "yyyy-mm" + const monthStr = `${year}-${String(month).padStart(2, '0')}`; + + try { + const response = await databases.listDocuments( + databaseId, + accountMetricsCollectionId, + [ + Query.equal("account_metrics_account_id", accountId), + Query.equal("account_metrics_month", monthStr), + Query.orderAsc("account_metrics_date") + ] + ); + + // Konvertiere Array zu Map: date -> metric + const metricsMap = new Map(); + for (const doc of response.documents) { + const date = doc.account_metrics_date; + if (date) { + metricsMap.set(date, doc); + } + } + + return metricsMap; + } catch (e) { + // Wenn Collection nicht existiert, gib leere Map zurück + if (e.code === 404 || e.type === 'collection_not_found') { + console.warn("account_metrics Collection existiert noch nicht. Bitte Schema erstellen."); + return new Map(); + } + console.error("Fehler beim Laden der Account-Metriken:", e); + throw e; + } +} + +/** + * Lädt die letzte erfolgreiche Account-Metrik + * @param {string} accountId - ID des Accounts + * @returns {Promise} Letzte erfolgreiche Metrik oder null + */ +export async function getLastSuccessfulAccountMetric(accountId) { + if (!accountId) { + throw new Error("accountId ist erforderlich"); + } + + try { + const response = await databases.listDocuments( + databaseId, + accountMetricsCollectionId, + [ + Query.equal("account_metrics_account_id", accountId), + Query.equal("account_metrics_refresh_status", "success"), + Query.orderDesc("account_metrics_date"), + Query.limit(1) + ] + ); + + if (response.documents.length > 0) { + return response.documents[0]; + } + + return null; + } catch (e) { + // Wenn Collection nicht existiert, gib null zurück + if (e.code === 404 || e.type === 'collection_not_found') { + return null; + } + console.error("Fehler beim Laden der letzten erfolgreichen Metrik:", e); + throw e; + } +} + diff --git a/Server/src/services/accountsService.js b/Server/src/services/accountsService.js index b6770e0..7ead44a 100644 --- a/Server/src/services/accountsService.js +++ b/Server/src/services/accountsService.js @@ -6,6 +6,7 @@ import { databases, databaseId, accountsCollectionId } from "../lib/appwrite"; import { ID, Query } from "appwrite"; +import { getLastSuccessfulAccountMetric as getLastSuccessfulAccountMetricFromService } from "./accountMetricsService"; /** * Lädt ein einzelnes Account nach ID @@ -201,6 +202,15 @@ export async function createManagedAccount(authUserId, accountData) { account_shop_name: accountData.account_shop_name || null, account_url: accountData.account_url || null, account_sells: accountData.account_sells ?? null, // Setze account_sells wenn verfügbar + // Neue Felder + account_response_time_hours: accountData.account_response_time_hours ?? null, + account_followers: accountData.account_followers ?? null, + account_feedback_total: accountData.account_feedback_total ?? null, + account_feedback_12m_positive: accountData.account_feedback_12m_positive ?? null, + account_feedback_12m_neutral: accountData.account_feedback_12m_neutral ?? null, + account_feedback_12m_negative: accountData.account_feedback_12m_negative ?? null, + account_last_refresh_at: accountData.account_last_refresh_at ?? null, + account_last_refresh_status: accountData.account_last_refresh_status ?? null, }; // account_status ist optional - aufgrund Schema-Konflikt vorerst weglassen @@ -323,3 +333,73 @@ export async function deleteManagedAccount(accountId) { throw e; } } + +/** + * Berechnet Data Freshness zur Laufzeit basierend auf account_last_refresh_at + * @param {string|null} lastRefreshAt - ISO 8601 Datum-String oder null + * @returns {string|null} "fresh" | "aging" | "outdated" | null + */ +export function calculateDataFreshness(lastRefreshAt) { + if (!lastRefreshAt) { + return null; + } + + try { + const refreshDate = new Date(lastRefreshAt); + const now = new Date(); + const diffMs = now - refreshDate; + const diffDays = diffMs / (1000 * 60 * 60 * 24); + + if (diffDays < 1) { + return "fresh"; + } else if (diffDays <= 3) { + return "aging"; + } else { + return "outdated"; + } + } catch (e) { + console.warn("Error calculating data freshness:", e); + return null; + } +} + +/** + * Bestimmt Refresh-Status basierend auf Partial Results + * Kernfelder: followers + feedbackTotal + (feedback12mPositive ODER responseTimeHours) + * @param {object} partialResults - Partial Results Objekt mit ok/error pro Sub-Scan + * @param {object} data - Extrahierte Daten (responseTimeHours, followers, feedbackTotal, feedback12mPositive) + * @returns {string} "success" | "partial" | "failed" + */ +export function determineRefreshStatus(partialResults, data) { + if (!partialResults || !data) { + return "failed"; + } + + // Kernfelder prüfen + const hasFollowers = data.followers !== null && data.followers !== undefined; + const hasFeedbackTotal = data.feedbackTotal !== null && data.feedbackTotal !== undefined; + const hasFeedback12m = data.feedback12mPositive !== null && data.feedback12mPositive !== undefined; + const hasResponseTime = data.responseTimeHours !== null && data.responseTimeHours !== undefined; + const hasThirdField = hasFeedback12m || hasResponseTime; + + const coreFieldsCount = (hasFollowers ? 1 : 0) + (hasFeedbackTotal ? 1 : 0) + (hasThirdField ? 1 : 0); + + if (coreFieldsCount >= 2) { + return "success"; + } else if (coreFieldsCount === 1) { + return "partial"; + } else { + return "failed"; + } +} + +/** + * Lädt die letzte erfolgreiche Account-Metrik + * Wrapper um accountMetricsService.getLastSuccessfulAccountMetric() + * @param {string} accountId - ID des Accounts + * @returns {Promise} Letzte erfolgreiche Metrik oder null + */ +export async function getLastSuccessfulAccountMetric(accountId) { + return await getLastSuccessfulAccountMetricFromService(accountId); +} + diff --git a/Server/src/services/ebayParserService.js b/Server/src/services/ebayParserService.js index fa22cf7..27d0b79 100644 --- a/Server/src/services/ebayParserService.js +++ b/Server/src/services/ebayParserService.js @@ -521,6 +521,101 @@ async function parseViaStub(url) { }; } +/** + * Parst eine eBay-URL erweitert und extrahiert Account-Daten inkl. Feedback, Response Time, Followers + * @param {string} url - eBay-Verkäuferprofil oder Shop-URL + * @returns {Promise<{sellerId, shopName, market, status, stats, responseTimeHours, followers, feedbackTotal, feedback12mPositive, feedback12mNeutral, feedback12mNegative, partialResults}>} + * @throws {Error} - Wenn URL ungültig ist oder keine eBay-URL + */ +export async function parseViaExtensionExtended(url) { + // Validierung + if (!url || typeof url !== 'string') { + throw new Error("Invalid URL"); + } + + // Methode 1: chrome.runtime.sendMessage (externally_connectable) + if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) { + try { + const extensionId = await getExtensionId(); + + if (!extensionId) { + throw new Error("Extension ID not available"); + } + + return new Promise((resolve, reject) => { + const message = { + action: "PARSE_ACCOUNT_EXTENDED", + url: url + }; + + const sendMessageCallback = (response) => { + if (chrome.runtime.lastError) { + const errorMsg = chrome.runtime.lastError.message || "Extension communication error"; + reject(new Error(errorMsg)); + return; + } + + if (response && response.ok && response.data) { + resolve(response.data); + } else { + reject(new Error(response?.error || "Extension parsing failed")); + } + }; + + chrome.runtime.sendMessage(extensionId, message, sendMessageCallback); + + // Timeout nach 90s (erhöht von 60s für 4 Tabs + Store-Suche) + setTimeout(() => { + reject(new Error("Extension timeout")); + }, 90000); + }); + } catch (error) { + if (error.message && !error.message.includes("Extension")) { + throw error; + } + // Weiter zu Methode 2 + } + } + + // Methode 2: Window flag + postMessage (falls Content Script Relay vorhanden) + if (typeof window !== 'undefined' && window.__EBAY_EXTENSION__ === true) { + return new Promise((resolve, reject) => { + const messageId = `parse_extended_${Date.now()}_${Math.random()}`; + + const responseHandler = (event) => { + if (event.data?.source !== 'eship-extension' || event.data?.messageId !== messageId) { + return; + } + + window.removeEventListener('message', responseHandler); + + if (event.data?.ok && event.data?.data) { + resolve(event.data.data); + } else { + reject(new Error(event.data?.error || "Extension parsing failed")); + } + }; + + window.addEventListener('message', responseHandler); + + window.postMessage({ + source: 'eship-webapp', + action: 'PARSE_ACCOUNT_EXTENDED', + url: url, + messageId: messageId + }, '*'); + + setTimeout(() => { + window.removeEventListener('message', responseHandler); + reject(new Error("Extension timeout")); + }, 60000); + }); + } + + // Keine Extension verfügbar + throw new Error("Extension not available"); +} + /** * Parst eine eBay-URL und extrahiert Account-Daten (Facade) * Versucht zuerst Extension-Pfad, fällt zurück auf Stub-Implementierung diff --git a/setup/create-account-metrics-collection.ps1 b/setup/create-account-metrics-collection.ps1 new file mode 100644 index 0000000..8806455 --- /dev/null +++ b/setup/create-account-metrics-collection.ps1 @@ -0,0 +1,86 @@ +# Appwrite schema script für account_metrics Collection +# Erstellt die account_metrics Collection mit allen Attributen und Indexes +# +# Prereqs: +# appwrite login +# appwrite init project +# +# Run: +# pwsh .\create-account-metrics-collection.ps1 -DatabaseId "YOUR_DATABASE_ID" + +param( + [Parameter(Mandatory = $true)] + [string]$DatabaseId +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ---------------- CONFIG ---------------- +$T_ACCOUNT_METRICS = "account_metrics" + +# Minimal offene Defaults +$PERMS_ANY_CRUD = @('create(any)','read(any)','update(any)','delete(any)') + +# ---------------- HELPERS ---------------- +function Try-Cmd { + param( + [Parameter(Mandatory = $true)] + [string[]]$Args + ) + + $cmd = "appwrite " + ($Args -join " ") + Write-Host ("+ " + $cmd) + + try { + & appwrite @Args | Out-Host + } catch { + Write-Host (" (ignored error) " + $_.Exception.Message) + } +} + +function Create-Table { + param( + [Parameter(Mandatory = $true)][string]$TableId, + [Parameter(Mandatory = $true)][string]$Name + ) + + $argsList = @( + "tables-db","create-table", + "--database-id",$DatabaseId, + "--table-id",$TableId, + "--name",$Name, + "--row-security","false" + ) + + Try-Cmd $argsList +} + +# ---------------- CREATE TABLE ---------------- +Create-Table -TableId $T_ACCOUNT_METRICS -Name "account_metrics" + +# ---------------- ACCOUNT_METRICS COLUMNS ---------------- +# Required attributes +Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_account_id","--size","64","--required","true","--array","false") +Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_date","--size","10","--required","true","--array","false") +Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_month","--size","7","--required","true","--array","false") + +# Refresh layer attributes +Try-Cmd @("tables-db","create-boolean-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_refreshed","--required","true","--array","false") +Try-Cmd @("tables-db","create-enum-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_refresh_status","--elements",'["success","failed"]',"--required","true","--array","false") +Try-Cmd @("tables-db","create-datetime-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_refreshed_at","--required","false","--array","false") + +# Sales layer attributes +Try-Cmd @("tables-db","create-integer-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_sales_count","--required","false","--array","false") +Try-Cmd @("tables-db","create-string-column","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","account_metrics_sales_bucket","--size","10","--required","false","--array","false") + +# ---------------- ACCOUNT_METRICS INDEXES ---------------- +# Unique index: ensures one doc per account per day +Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","uniq_account_date","--type","unique","--columns","account_metrics_account_id","account_metrics_date") + +# Index for month queries +Try-Cmd @("tables-db","create-index","--database-id",$DatabaseId,"--table-id",$T_ACCOUNT_METRICS,"--key","idx_account_month","--type","key","--columns","account_metrics_account_id","account_metrics_month") + +Write-Host "" +Write-Host "account_metrics Collection erstellt!" +Write-Host ""