├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE ├── README.md ├── admin ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── components.json ├── eslint.config.mjs ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public │ ├── file.svg │ ├── globe.svg │ ├── next.svg │ ├── vercel.svg │ └── window.svg ├── src │ ├── app │ │ ├── dashboard │ │ │ ├── importers │ │ │ │ ├── [id] │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── preview │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── new │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── metrics │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── login │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── signup │ │ │ └── page.tsx │ ├── components │ │ ├── AddColumnForm.tsx │ │ ├── ColumnManager.tsx │ │ ├── ImporterColumnsManager.tsx │ │ └── ui │ │ │ ├── alert-dialog.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── progress.tsx │ │ │ ├── select.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ └── use-toast.ts │ ├── context │ │ └── AuthContext.tsx │ ├── lib │ │ └── utils.ts │ └── utils │ │ └── apiClient.ts └── tsconfig.json ├── backend ├── .dockerignore ├── .env.example ├── .gitignore ├── .python-version ├── Dockerfile ├── README.md ├── alembic.ini ├── app │ ├── api │ │ ├── routes.py │ │ └── v1 │ │ │ ├── auth.py │ │ │ ├── importers.py │ │ │ └── imports.py │ ├── auth │ │ ├── __init__.py │ │ ├── token.py │ │ └── users.py │ ├── core │ │ └── config.py │ ├── db │ │ ├── base.py │ │ ├── init_db.py │ │ ├── models.py │ │ ├── users.py │ │ └── utils.py │ ├── main.py │ ├── models │ │ ├── import_job.py │ │ ├── importer.py │ │ ├── token.py │ │ ├── user.py │ │ └── webhook.py │ ├── schemas │ │ ├── auth.py │ │ ├── import_job.py │ │ ├── importer.py │ │ ├── schema.py │ │ ├── token.py │ │ ├── user.py │ │ └── webhook.py │ ├── services │ │ ├── auth.py │ │ ├── import_service.py │ │ ├── importer.py │ │ ├── queue.py │ │ └── webhook.py │ ├── worker.py │ └── workers │ │ ├── __init__.py │ │ └── import_worker.py ├── create_migration.py ├── init_app.py ├── migrations │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 34203d60870b_initial_migration_based_on_current_.py │ │ ├── 98f92bc3f715_add_webhook_data_fields.py │ │ ├── add_token_blacklist.py │ │ └── c734150f184e_add_key_to_importer.py ├── pytest.ini ├── requirements.txt └── scripts │ └── reset_password.py ├── docker-compose.yml ├── docs └── assets │ ├── demo.mp4 │ ├── importer.png │ ├── mapping.png │ ├── schema.png │ ├── validation.png │ └── webhooks.png └── frontend ├── .babelrc.json ├── .gitignore ├── .prettierrc ├── .yarnrc.yml ├── LICENSE ├── README.md ├── package-js.json ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── rollup.config-js.js ├── rollup.config.js ├── src ├── App.jsx ├── components │ └── CSVImporter │ │ ├── index.tsx │ │ └── style │ │ └── csv-importer.css ├── i18n │ ├── de.ts │ ├── es.ts │ ├── fr.ts │ ├── i18n.ts │ └── it.ts ├── importer │ ├── components │ │ ├── Box │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ └── Box.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── Checkbox │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ └── Checkbox.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── Errors │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ └── Errors.module.scss │ │ ├── Input │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ ├── Input.module.scss │ │ │ │ └── mixins.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── Portal │ │ │ ├── index.tsx │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── Stepper │ │ │ ├── hooks │ │ │ │ └── useStepper.ts │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ └── Stepper.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── Table │ │ │ ├── index.tsx │ │ │ ├── storyData.ts │ │ │ ├── style │ │ │ │ └── Default.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── ToggleFilter │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ └── ToggleFilter.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── Tooltip │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ └── Tooltip.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── UploaderWrapper │ │ │ ├── UploaderWrapper.tsx │ │ │ ├── style │ │ │ │ └── uppy.overrides.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── select.tsx │ │ │ └── tooltip.tsx │ ├── features │ │ ├── complete │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ └── Complete.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── main │ │ │ ├── hooks │ │ │ │ ├── useMutableLocalStorage.ts │ │ │ │ └── useStepNavigation.ts │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ └── Main.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── map-columns │ │ │ ├── components │ │ │ │ └── DropDownFields.tsx │ │ │ ├── hooks │ │ │ │ ├── useMapColumnsTable.tsx │ │ │ │ └── useNameChange.ts │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ └── MapColumns.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── row-selection │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ └── RowSelection.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ ├── uploader │ │ │ ├── hooks │ │ │ │ └── useTemplateTable.tsx │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ └── Uploader.module.scss │ │ │ └── types │ │ │ │ └── index.ts │ │ └── validation │ │ │ ├── Validation.tsx │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ └── Validation.module.scss │ │ │ └── types.ts │ ├── hooks │ │ ├── useClickOutside.ts │ │ ├── useCustomStyles.ts │ │ ├── useDelayLoader.ts │ │ ├── useEventListener.ts │ │ ├── useIsomorphicLayoutEffect.ts │ │ ├── useRect.ts │ │ └── useWindowSize.ts │ ├── providers │ │ ├── Theme.tsx │ │ ├── index.tsx │ │ └── types │ │ │ └── index.ts │ ├── services │ │ └── api.ts │ ├── settings │ │ ├── chakra │ │ │ ├── components │ │ │ │ ├── alert.ts │ │ │ │ ├── button.ts │ │ │ │ └── index.ts │ │ │ ├── foundations │ │ │ │ ├── blur.ts │ │ │ │ ├── borders.ts │ │ │ │ ├── breakpoints.ts │ │ │ │ ├── colors.ts │ │ │ │ ├── index.ts │ │ │ │ ├── radius.ts │ │ │ │ ├── shadows.ts │ │ │ │ ├── sizes.ts │ │ │ │ ├── spacing.ts │ │ │ │ ├── transition.ts │ │ │ │ ├── typography.ts │ │ │ │ └── z-index.ts │ │ │ ├── index.ts │ │ │ ├── semantic-tokens.ts │ │ │ ├── styles.ts │ │ │ ├── theme.types.ts │ │ │ └── utils │ │ │ │ ├── is-chakra-theme.ts │ │ │ │ └── run-if-fn.ts │ │ └── theme │ │ │ ├── colors.ts │ │ │ ├── index.ts │ │ │ └── sizes.ts │ ├── stores │ │ └── theme.ts │ ├── style │ │ ├── design-system │ │ │ └── colors.scss │ │ ├── fonts.scss │ │ ├── index.scss │ │ ├── mixins.scss │ │ ├── themes │ │ │ ├── common.scss │ │ │ ├── dark.scss │ │ │ └── light.scss │ │ └── vars.scss │ ├── types │ │ └── index.ts │ └── utils │ │ ├── classes.ts │ │ ├── debounce.ts │ │ ├── getStringLengthOfChildren.ts │ │ ├── stringSimilarity.ts │ │ ├── template.ts │ │ └── utils.ts ├── index.css ├── index.js ├── index.ts ├── js.tsx ├── services │ ├── api.js │ └── apiClient.js ├── settings │ └── defaults.ts ├── styles.d.ts ├── types │ └── index.ts └── utils │ └── classes.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # production 12 | build 13 | dist 14 | 15 | # misc 16 | .DS_Store 17 | *.pem 18 | 19 | # debug 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # local env files 25 | .env 26 | .env.local 27 | .env.development.local 28 | .env.test.local 29 | .env.production.local 30 | 31 | # Python 32 | __pycache__/ 33 | **/__pycache__/ 34 | *.py[cod] 35 | *$py.class 36 | **/*.pyc 37 | **/*.pyo 38 | **/*.pyd 39 | *.so 40 | .Python 41 | build/ 42 | develop-eggs/ 43 | dist/ 44 | downloads/ 45 | eggs/ 46 | .eggs/ 47 | parts/ 48 | sdist/ 49 | var/ 50 | wheels/ 51 | *.egg-info/ 52 | .installed.cfg 53 | *.egg 54 | 55 | # Virtual Environment 56 | venv/ 57 | ENV/ 58 | env/ 59 | .env 60 | .venv 61 | 62 | # Database 63 | *.db 64 | *.sqlite3 65 | *.sqlite 66 | 67 | # Uploads and user content 68 | uploads/ 69 | 70 | # Logs 71 | *.log 72 | logs/ 73 | 74 | # IDE specific files 75 | .idea/ 76 | .vscode/ 77 | *.swp 78 | *.swo 79 | 80 | # FastAPI specific 81 | __pycache__/ 82 | **/__pycache__/ 83 | .pytest_cache/ 84 | **/.pytest_cache/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | npm-debug.log* 89 | yarn-debug.log* 90 | yarn-error.log* 91 | .pnpm-debug.log* 92 | 93 | # local env files 94 | .env.local 95 | .env.development.local 96 | .env.test.local 97 | .env.production.local 98 | **/.env 99 | 100 | # turbo 101 | .turbo 102 | 103 | # IDEs 104 | .vscode 105 | .idea 106 | 107 | # nuxt 108 | .output 109 | build -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | shamefully-hoist=true 3 | public-hoist-pattern[]=*prisma* 4 | strict-peer-dependencies=false 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 80, 4 | "singleQuote": false, 5 | "jsxSingleQuote": false, 6 | "semi": true, 7 | "trailingComma": "all", 8 | "tabWidth": 2, 9 | "vueIndentScriptAndStyle": true 10 | } 11 | -------------------------------------------------------------------------------- /admin/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | .git 4 | .yalc 5 | .vscode 6 | *.log 7 | -------------------------------------------------------------------------------- /admin/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | # IDE and editor files 44 | .idea/ 45 | .vscode/* 46 | !.vscode/extensions.json 47 | !.vscode/settings.json 48 | !.vscode/tasks.json 49 | !.vscode/launch.json 50 | 51 | # Local development files 52 | .env.local 53 | .env.development.local 54 | .env.test.local 55 | .env.production.local 56 | 57 | # PWA files 58 | public/sw.js 59 | public/workbox-*.js 60 | public/worker-*.js 61 | public/sw.js.map 62 | public/workbox-*.js.map 63 | public/worker-*.js.map 64 | 65 | # Storybook 66 | /storybook-static 67 | 68 | # Logs 69 | logs 70 | *.log 71 | 72 | # Yalc local package management 73 | .yalc/ 74 | yalc.lock 75 | -------------------------------------------------------------------------------- /admin/Dockerfile: -------------------------------------------------------------------------------- 1 | # Multi-stage build for admin app with frontend integration 2 | 3 | # Stage 1: Build the frontend 4 | FROM node:18 AS frontend-builder 5 | WORKDIR /frontend 6 | 7 | # Copy frontend source files 8 | COPY frontend/package.json frontend/package-lock.json ./ 9 | RUN npm install --legacy-peer-deps 10 | COPY frontend/ ./ 11 | 12 | # Build the frontend (using the React build script) 13 | RUN npm run build:react 14 | 15 | # Stage 2: Build the admin app 16 | FROM node:18 17 | WORKDIR /app 18 | 19 | # Copy package.json and install dependencies 20 | COPY admin/package.json admin/package-lock.json ./ 21 | RUN npm install --legacy-peer-deps 22 | 23 | # Remove the csv-import-react package if it exists 24 | RUN rm -rf ./node_modules/csv-import-react 25 | 26 | # Create the directory structure for the csv-import-react package 27 | RUN mkdir -p ./node_modules/csv-import-react/build 28 | 29 | # Copy the built frontend from the first stage 30 | COPY --from=frontend-builder /frontend/package.json ./node_modules/csv-import-react/ 31 | COPY --from=frontend-builder /frontend/build/ ./node_modules/csv-import-react/build/ 32 | 33 | # Copy admin source files (after handling node_modules) 34 | COPY admin/ ./ 35 | 36 | # Expose port 37 | EXPOSE 3000 38 | 39 | # Start the development server 40 | CMD ["npm", "run", "dev"] -------------------------------------------------------------------------------- /admin/README.md: -------------------------------------------------------------------------------- 1 | # ImportCSV Admin Dashboard 2 | 3 | A Next.js-based admin interface for the ImportCSV application. 4 | 5 | ## Overview 6 | 7 | This admin dashboard provides an interface for managing CSV imports and configurations. 8 | 9 | ## Technology Stack 10 | 11 | - **Framework**: Next.js 12 | - **Language**: TypeScript 13 | - **Styling**: Tailwind CSS 14 | - **UI Components**: Radix UI components 15 | 16 | ## Project Structure 17 | 18 | ``` 19 | admin/ 20 | ├── src/ 21 | │ ├── app/ # Next.js App Router pages 22 | │ ├── components/ # Reusable UI components 23 | │ ├── context/ # React context providers 24 | │ ├── lib/ # Utility libraries 25 | │ ├── types/ # TypeScript type definitions 26 | │ └── utils/ # Helper functions 27 | ├── public/ # Static assets 28 | └── package.json # Dependencies and scripts 29 | ``` 30 | 31 | ## Getting Started 32 | 33 | ### Prerequisites 34 | 35 | - Node.js 36 | - Backend API running 37 | 38 | ### Installation 39 | 40 | 1. Install dependencies: 41 | 42 | ```bash 43 | npm install 44 | # or 45 | yarn install 46 | ``` 47 | 48 | 2. Run the development server: 49 | 50 | ```bash 51 | npm run dev 52 | # or 53 | yarn dev 54 | ``` 55 | 56 | 3. Open [http://localhost:3000](http://localhost:3000) with your browser to see the dashboard. 57 | 58 | ## Scripts 59 | 60 | - `npm run dev` - Start development server 61 | - `npm run build` - Build for production 62 | - `npm run start` - Start production server 63 | - `npm run lint` - Run linter 64 | 65 | ## License 66 | 67 | This project is licensed under the Apache License 2.0 - see the [LICENSE](../LICENSE) file for details. 68 | -------------------------------------------------------------------------------- /admin/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /admin/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /admin/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /admin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "importcsv-admin", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@emotion/react": "^11.14.0", 13 | "@emotion/styled": "^11.14.0", 14 | "@radix-ui/react-alert-dialog": "^1.1.7", 15 | "@radix-ui/react-dialog": "^1.1.7", 16 | "@radix-ui/react-dropdown-menu": "^2.1.7", 17 | "@radix-ui/react-label": "^2.1.3", 18 | "@radix-ui/react-progress": "^1.1.3", 19 | "@radix-ui/react-select": "^2.1.7", 20 | "@radix-ui/react-slot": "^1.2.0", 21 | "@radix-ui/react-switch": "^1.1.4", 22 | "@radix-ui/react-tabs": "^1.1.4", 23 | "@radix-ui/react-toast": "^1.2.11", 24 | "axios": "^1.9.0", 25 | "class-variance-authority": "^0.7.1", 26 | "clsx": "^2.1.1", 27 | "csv-import-react": "file:.yalc/csv-import-react", 28 | "framer-motion": "^12.7.4", 29 | "lucide-react": "^0.488.0", 30 | "next": "15.3.1", 31 | "react": "^19.0.0", 32 | "react-dom": "^19.0.0", 33 | "tailwind-merge": "^3.2.0", 34 | "tw-animate-css": "^1.2.5" 35 | }, 36 | "devDependencies": { 37 | "@eslint/eslintrc": "^3", 38 | "@tailwindcss/postcss": "^4", 39 | "@types/node": "^20", 40 | "@types/react": "^19", 41 | "@types/react-dom": "^19", 42 | "eslint": "^9", 43 | "eslint-config-next": "15.3.1", 44 | "tailwindcss": "^4", 45 | "typescript": "^5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /admin/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /admin/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /admin/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /admin/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /admin/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /admin/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /admin/src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect } from 'react'; 4 | import { useAuth } from '@/context/AuthContext'; 5 | import { useRouter } from 'next/navigation'; 6 | 7 | export default function DashboardPage() { 8 | const { isAuthenticated, isLoading, logout } = useAuth(); 9 | const router = useRouter(); 10 | 11 | useEffect(() => { 12 | if (!isLoading && !isAuthenticated) { 13 | router.push('/login'); 14 | } else if (!isLoading && isAuthenticated) { 15 | // Redirect to importers page 16 | router.push('/dashboard/importers'); 17 | } 18 | }, [isAuthenticated, isLoading, router]); 19 | 20 | const handleLogout = () => { 21 | logout(); 22 | router.push('/login'); 23 | }; 24 | 25 | if (isLoading) { 26 | return ( 27 |
28 |

Loading...

29 |
30 | ); 31 | } 32 | 33 | if (!isAuthenticated) { 34 | return null; 35 | } 36 | 37 | return ( 38 |
39 |

Redirecting to importers...

40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /admin/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishekray07/importcsv/205669cc0d0bf442c5b2cacd22ef56bc3f2ec457/admin/src/app/favicon.ico -------------------------------------------------------------------------------- /admin/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { AuthProvider } from "@/context/AuthContext"; // Import AuthProvider 5 | import { Toaster } from "@/components/ui/toaster"; 6 | 7 | const geistSans = Geist({ 8 | variable: "--font-geist-sans", 9 | subsets: ["latin"], 10 | }); 11 | 12 | const geistMono = Geist_Mono({ 13 | variable: "--font-geist-mono", 14 | subsets: ["latin"], 15 | }); 16 | 17 | export const metadata: Metadata = { 18 | title: "ImportCSV", 19 | description: "Admin dashboard for ImportCSV application", 20 | }; 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: Readonly<{ 25 | children: React.ReactNode; 26 | }>) { 27 | return ( 28 | 29 | 32 | 33 | {children} 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /admin/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useAuth } from '@/context/AuthContext'; 6 | 7 | export default function Home() { 8 | const router = useRouter(); 9 | const { isAuthenticated, isLoading } = useAuth(); 10 | 11 | useEffect(() => { 12 | if (!isLoading) { 13 | if (isAuthenticated) { 14 | router.push('/dashboard'); 15 | } else { 16 | router.push('/login'); 17 | } 18 | } 19 | }, [isAuthenticated, isLoading, router]); 20 | 21 | // Show a simple loading state while checking authentication 22 | return ( 23 |
24 |
25 |

ImportCSV Admin

26 |

Redirecting...

27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /admin/src/components/ImporterColumnsManager.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useState } from 'react'; 4 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 5 | import { ImporterField } from './AddColumnForm'; 6 | import ColumnManager from './ColumnManager'; 7 | 8 | interface ImporterColumnsManagerProps { 9 | initialColumns: ImporterField[]; 10 | onColumnsChange: (columns: ImporterField[]) => void; 11 | } 12 | 13 | export default function ImporterColumnsManager({ 14 | initialColumns = [], 15 | onColumnsChange 16 | }: ImporterColumnsManagerProps) { 17 | const [columns, setColumns] = useState(initialColumns); 18 | 19 | // Add a new column 20 | const handleAddColumn = (field: ImporterField) => { 21 | const updatedColumns = [...columns, field]; 22 | setColumns(updatedColumns); 23 | onColumnsChange(updatedColumns); 24 | }; 25 | 26 | // Edit an existing column 27 | const handleEditColumn = (index: number, field: ImporterField) => { 28 | const updatedColumns = [...columns]; 29 | updatedColumns[index] = field; 30 | setColumns(updatedColumns); 31 | onColumnsChange(updatedColumns); 32 | }; 33 | 34 | // Delete a column 35 | const handleDeleteColumn = (index: number) => { 36 | const updatedColumns = columns.filter((_, i) => i !== index); 37 | setColumns(updatedColumns); 38 | onColumnsChange(updatedColumns); 39 | }; 40 | 41 | return ( 42 | 43 | 44 |

Define the columns for your CSV imports.

45 |
46 | 47 | 53 | 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /admin/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /admin/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /admin/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /admin/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /admin/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /admin/src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Progress({ 9 | className, 10 | value, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 22 | 27 | 28 | ) 29 | } 30 | 31 | export { Progress } 32 | -------------------------------------------------------------------------------- /admin/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitive from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Switch({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | 27 | 28 | ) 29 | } 30 | 31 | export { Switch } 32 | -------------------------------------------------------------------------------- /admin/src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Table({ className, ...props }: React.ComponentProps<"table">) { 8 | return ( 9 |
13 | 18 | 19 | ) 20 | } 21 | 22 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 23 | return ( 24 | 29 | ) 30 | } 31 | 32 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { 33 | return ( 34 | 39 | ) 40 | } 41 | 42 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { 43 | return ( 44 | tr]:last:border-b-0", 48 | className 49 | )} 50 | {...props} 51 | /> 52 | ) 53 | } 54 | 55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) { 56 | return ( 57 | 65 | ) 66 | } 67 | 68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) { 69 | return ( 70 |
[role=checkbox]]:translate-y-[2px]", 74 | className 75 | )} 76 | {...props} 77 | /> 78 | ) 79 | } 80 | 81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) { 82 | return ( 83 | [role=checkbox]]:translate-y-[2px]", 87 | className 88 | )} 89 | {...props} 90 | /> 91 | ) 92 | } 93 | 94 | function TableCaption({ 95 | className, 96 | ...props 97 | }: React.ComponentProps<"caption">) { 98 | return ( 99 |
104 | ) 105 | } 106 | 107 | export { 108 | Table, 109 | TableHeader, 110 | TableBody, 111 | TableFooter, 112 | TableHead, 113 | TableRow, 114 | TableCell, 115 | TableCaption, 116 | } 117 | -------------------------------------------------------------------------------- /admin/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Tabs({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 18 | ) 19 | } 20 | 21 | function TabsList({ 22 | className, 23 | ...props 24 | }: React.ComponentProps) { 25 | return ( 26 | 34 | ) 35 | } 36 | 37 | function TabsTrigger({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ) 51 | } 52 | 53 | function TabsContent({ 54 | className, 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 63 | ) 64 | } 65 | 66 | export { Tabs, TabsList, TabsTrigger, TabsContent } 67 | -------------------------------------------------------------------------------- /admin/src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Toast, 5 | ToastClose, 6 | ToastDescription, 7 | ToastProvider, 8 | ToastTitle, 9 | ToastViewport, 10 | } from "@/components/ui/toast" 11 | import { useToast } from "@/components/ui/use-toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /admin/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *$py.class 4 | *.so 5 | .Python 6 | venv/ 7 | env/ 8 | .env 9 | .env.local 10 | .pytest_cache/ 11 | .coverage 12 | htmlcov/ 13 | .tox/ 14 | .nox/ 15 | .hypothesis/ 16 | .idea/ 17 | .vscode/ 18 | *.log 19 | uploads/ 20 | test_uploads/ 21 | *.db 22 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # Environment 2 | ENVIRONMENT=development 3 | 4 | # Database settings 5 | DATABASE_URL=postgresql://postgres:postgres@localhost:5432/importcsv 6 | 7 | # JWT settings 8 | SECRET_KEY=your-secret-key-for-jwt 9 | ACCESS_TOKEN_EXPIRE_MINUTES=30 10 | 11 | # Webhook settings 12 | WEBHOOK_SECRET=your-webhook-secret 13 | 14 | # Redis and RQ settings 15 | REDIS_URL=redis://localhost:6379 16 | RQ_DEFAULT_TIMEOUT=3600 17 | RQ_IMPORT_QUEUE=imports 18 | 19 | # Admin user (for initialization) 20 | ADMIN_EMAIL=admin@example.com 21 | ADMIN_PASSWORD=admin123 22 | ADMIN_NAME=Admin User 23 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | **/__pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | *.so 7 | .Python 8 | **/*.pyc 9 | **/*.pyo 10 | **/*.pyd 11 | .coverage 12 | .coverage.* 13 | .cache 14 | nosetests.xml 15 | coverage.xml 16 | *.cover 17 | *.so 18 | .Python 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | 35 | # Virtual Environment 36 | venv/ 37 | ENV/ 38 | env/ 39 | .env 40 | .venv 41 | 42 | # Database 43 | *.db 44 | *.sqlite3 45 | *.sqlite 46 | 47 | # Uploads and user content 48 | uploads/ 49 | 50 | # Logs 51 | *.log 52 | logs/ 53 | 54 | # IDE specific files 55 | .idea/ 56 | .vscode/ 57 | *.swp 58 | *.swo 59 | .DS_Store 60 | 61 | # Environment variables 62 | .env 63 | .env.local 64 | .env.development.local 65 | .env.test.local 66 | .env.production.local 67 | 68 | # Cache 69 | .pytest_cache/ 70 | .coverage 71 | htmlcov/ 72 | .tox/ 73 | .nox/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # FastAPI specific 79 | __pycache__/ 80 | .pytest_cache/ 81 | -------------------------------------------------------------------------------- /backend/.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /app 4 | 5 | # Install dependencies 6 | COPY requirements.txt . 7 | RUN pip install --no-cache-dir -r requirements.txt 8 | 9 | # Copy application code 10 | COPY . . 11 | 12 | # Create upload directory 13 | RUN mkdir -p uploads 14 | 15 | # Expose port 16 | EXPOSE 8000 17 | 18 | # Run the application 19 | CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] 20 | -------------------------------------------------------------------------------- /backend/app/api/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.v1 import auth, importers, imports 4 | 5 | api_router = APIRouter() 6 | 7 | # Include all API routes 8 | # Auth routes (FastAPI-Users authentication) 9 | api_router.include_router(auth.router, prefix="/v1/auth", tags=["Authentication"]) 10 | 11 | # Other API routes 12 | api_router.include_router(importers.router, prefix="/v1/importers", tags=["Importers"]) 13 | api_router.include_router(imports.router, prefix="/v1/imports", tags=["Imports"]) 14 | 15 | # Key-authenticated routes (no user authentication required) 16 | api_router.include_router(imports.key_router, prefix="/v1/imports", tags=["Key Imports"]) 17 | -------------------------------------------------------------------------------- /backend/app/api/v1/importers.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from fastapi import APIRouter, Depends, HTTPException, status 3 | from sqlalchemy.orm import Session 4 | from typing import List 5 | 6 | from app.db.base import get_db 7 | from app.auth.users import get_current_active_user 8 | from app.models.user import User 9 | from app.schemas.importer import ImporterCreate, ImporterUpdate, Importer as ImporterSchema 10 | from app.services import importer as importer_service 11 | 12 | router = APIRouter() 13 | 14 | @router.get("/", response_model=List[ImporterSchema]) 15 | async def read_importers( 16 | db: Session = Depends(get_db), 17 | skip: int = 0, 18 | limit: int = 100, 19 | current_user: User = Depends(get_current_active_user), 20 | ): 21 | """ 22 | Retrieve importers 23 | """ 24 | return importer_service.get_importers(db, str(current_user.id), skip, limit) 25 | 26 | 27 | @router.post("/", response_model=ImporterSchema) 28 | async def create_importer( 29 | importer_in: ImporterCreate, 30 | db: Session = Depends(get_db), 31 | current_user: User = Depends(get_current_active_user), 32 | ): 33 | """ 34 | Create new importer 35 | """ 36 | return importer_service.create_importer(db, str(current_user.id), importer_in) 37 | 38 | 39 | @router.get("/{importer_id}", response_model=ImporterSchema) 40 | async def read_importer( 41 | importer_id: uuid.UUID, 42 | db: Session = Depends(get_db), 43 | current_user: User = Depends(get_current_active_user), 44 | ): 45 | """ 46 | Get importer by ID 47 | """ 48 | importer = importer_service.get_importer(db, str(current_user.id), importer_id) 49 | if not importer: 50 | raise HTTPException(status_code=404, detail="Importer not found") 51 | return importer 52 | 53 | 54 | @router.put("/{importer_id}", response_model=ImporterSchema) 55 | async def update_importer( 56 | importer_id: uuid.UUID, 57 | importer_in: ImporterUpdate, 58 | db: Session = Depends(get_db), 59 | current_user: User = Depends(get_current_active_user), 60 | ): 61 | """ 62 | Update an importer 63 | """ 64 | importer = importer_service.update_importer(db, str(current_user.id), importer_id, importer_in) 65 | if not importer: 66 | raise HTTPException(status_code=404, detail="Importer not found") 67 | return importer 68 | 69 | @router.delete("/{importer_id}", status_code=status.HTTP_204_NO_CONTENT) 70 | async def delete_importer( 71 | importer_id: uuid.UUID, 72 | db: Session = Depends(get_db), 73 | current_user: User = Depends(get_current_active_user), 74 | ): 75 | """ 76 | Delete an importer 77 | """ 78 | importer = importer_service.delete_importer(db, str(current_user.id), importer_id) 79 | if not importer: 80 | raise HTTPException(status_code=404, detail="Importer not found") 81 | return None 82 | -------------------------------------------------------------------------------- /backend/app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /backend/app/auth/users.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import logging 3 | from typing import Optional 4 | 5 | from fastapi import Depends, Request 6 | from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin 7 | from fastapi_users.authentication import AuthenticationBackend, BearerTransport, CookieTransport, JWTStrategy 8 | 9 | from app.core.config import settings 10 | from app.db.users import get_user_db 11 | from app.models.user import User 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): 17 | reset_password_token_secret = settings.SECRET_KEY 18 | verification_token_secret = settings.SECRET_KEY 19 | 20 | async def on_after_register(self, user: User, request: Optional[Request] = None): 21 | logger.info(f"User {user.id} has registered.") 22 | 23 | async def on_after_forgot_password( 24 | self, user: User, token: str, request: Optional[Request] = None 25 | ): 26 | # In production, you would send an email here 27 | logger.info(f"User {user.id} has forgot their password. Reset token: {token}") 28 | 29 | async def on_after_request_verify( 30 | self, user: User, token: str, request: Optional[Request] = None 31 | ): 32 | # In production, you would send an email here 33 | logger.info(f"Verification requested for user {user.id}. Verification token: {token}") 34 | 35 | 36 | async def get_user_manager(user_db=Depends(get_user_db)): 37 | yield UserManager(user_db) 38 | 39 | 40 | # Bearer transport for API access 41 | bearer_transport = BearerTransport(tokenUrl=f"{settings.API_V1_STR}/auth/login") 42 | 43 | # Cookie transport for web applications 44 | cookie_transport = CookieTransport( 45 | cookie_name="importcsv_auth", 46 | cookie_max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, 47 | cookie_secure=settings.ENVIRONMENT == "production", # Only send over HTTPS in production 48 | cookie_httponly=True, # Prevent JavaScript access 49 | cookie_samesite="lax", # CSRF protection 50 | ) 51 | 52 | 53 | def get_jwt_strategy() -> JWTStrategy: 54 | return JWTStrategy( 55 | secret=settings.SECRET_KEY, 56 | lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, 57 | token_audience=["fastapi-users:auth"], 58 | ) 59 | 60 | 61 | # JWT Authentication backend for API access 62 | jwt_backend = AuthenticationBackend( 63 | name="jwt", 64 | transport=bearer_transport, 65 | get_strategy=get_jwt_strategy, 66 | ) 67 | 68 | # Cookie Authentication backend for web applications 69 | cookie_backend = AuthenticationBackend( 70 | name="cookie", 71 | transport=cookie_transport, 72 | get_strategy=get_jwt_strategy, 73 | ) 74 | 75 | # Create FastAPIUsers instance 76 | fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [jwt_backend, cookie_backend]) 77 | 78 | # Export dependencies 79 | get_current_user = fastapi_users.current_user() 80 | get_current_active_user = fastapi_users.current_user(active=True) 81 | get_current_superuser = fastapi_users.current_user(active=True, superuser=True) 82 | get_optional_user = fastapi_users.current_user(optional=True) 83 | -------------------------------------------------------------------------------- /backend/app/db/models.py: -------------------------------------------------------------------------------- 1 | # Import all models here to ensure they are registered with SQLAlchemy 2 | # before any relationships are resolved 3 | 4 | # This file is imported by base.py to ensure all models are loaded 5 | # Using import strings to avoid circular imports 6 | 7 | # Import the modules, not the classes directly 8 | import app.models.user 9 | import app.models.import_job 10 | import app.models.importer 11 | import app.models.webhook 12 | 13 | # Add any new model imports here 14 | -------------------------------------------------------------------------------- /backend/app/db/users.py: -------------------------------------------------------------------------------- 1 | """User database utilities for FastAPI Users. 2 | 3 | This module provides the necessary database dependencies for FastAPI Users. 4 | """ 5 | from fastapi import Depends 6 | from fastapi_users.db import SQLAlchemyUserDatabase 7 | from sqlalchemy.ext.asyncio import AsyncSession 8 | 9 | from app.db.base import get_async_session 10 | from app.models.user import User 11 | 12 | async def get_user_db(session: AsyncSession = Depends(get_async_session)): 13 | """Get a SQLAlchemy user database instance. 14 | 15 | This is a dependency that will be used by FastAPI Users. 16 | """ 17 | yield SQLAlchemyUserDatabase(session, User) 18 | -------------------------------------------------------------------------------- /backend/app/db/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from contextlib import contextmanager 3 | from sqlalchemy.orm import Session 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | @contextmanager 8 | def db_transaction(db: Session): 9 | """ 10 | Context manager for database transactions. 11 | 12 | Usage: 13 | with db_transaction(db): 14 | # Your database operations here 15 | db.add(item) 16 | # No need to call db.commit() - it's handled by the context manager 17 | 18 | Args: 19 | db (Session): The SQLAlchemy database session 20 | 21 | Raises: 22 | Exception: Any exception that occurs during the transaction will be re-raised 23 | after the rollback is performed 24 | """ 25 | try: 26 | yield 27 | db.commit() 28 | except Exception as e: 29 | db.rollback() 30 | logger.error(f"Transaction failed: {str(e)}") 31 | raise 32 | -------------------------------------------------------------------------------- /backend/app/models/import_job.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import uuid 3 | 4 | from sqlalchemy import Column, Integer, String, JSON, DateTime, ForeignKey, Enum, UUID 5 | from sqlalchemy.sql import func 6 | from sqlalchemy.orm import relationship 7 | 8 | from app.db.base import Base 9 | 10 | # Import models using strings in relationships to avoid circular imports 11 | 12 | 13 | class ImportStatus(str, enum.Enum): 14 | PENDING = "pending" 15 | PROCESSING = "processing" 16 | VALIDATING = "validating" 17 | VALIDATED = "validated" 18 | IMPORTING = "importing" 19 | COMPLETED = "completed" 20 | FAILED = "failed" 21 | 22 | 23 | class ImportJob(Base): 24 | __tablename__ = "import_jobs" 25 | 26 | id = Column(UUID, primary_key=True, default=uuid.uuid4) 27 | user_id = Column(UUID, ForeignKey("users.id"), nullable=False) 28 | importer_id = Column(UUID, ForeignKey("importers.id"), nullable=False) 29 | file_name = Column(String, nullable=False) 30 | file_path = Column(String, nullable=False) 31 | file_type = Column(String, nullable=False) # csv, xlsx, etc. 32 | status = Column(Enum(ImportStatus), default=ImportStatus.PENDING, nullable=False) 33 | row_count = Column(Integer, default=0, nullable=False) 34 | processed_rows = Column(Integer, default=0, nullable=False) 35 | error_count = Column(Integer, default=0, nullable=False) 36 | errors = Column(JSON, nullable=True) # Store validation errors 37 | column_mapping = Column( 38 | JSON, nullable=True 39 | ) # Mapping of file columns to schema fields 40 | file_metadata = Column(JSON, nullable=True) # Additional metadata 41 | processed_data = Column( 42 | JSON, nullable=True 43 | ) # Store processed data (valid and invalid records) 44 | error_message = Column( 45 | String, nullable=True 46 | ) # Store error message if processing fails 47 | created_at = Column(DateTime(timezone=True), server_default=func.now()) 48 | updated_at = Column(DateTime(timezone=True), onupdate=func.now()) 49 | completed_at = Column(DateTime(timezone=True), nullable=True) 50 | 51 | # Relationships - using simple string references 52 | user = relationship("User", back_populates="import_jobs") 53 | importer = relationship("Importer", back_populates="import_jobs") 54 | webhook_events = relationship("WebhookEvent", back_populates="import_job") 55 | -------------------------------------------------------------------------------- /backend/app/models/importer.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from sqlalchemy import ( 3 | Column, 4 | Integer, 5 | String, 6 | JSON, 7 | DateTime, 8 | ForeignKey, 9 | UUID, 10 | Boolean, 11 | ) 12 | from sqlalchemy.sql import func 13 | from sqlalchemy.orm import relationship 14 | 15 | from app.db.base import Base 16 | 17 | 18 | class Importer(Base): 19 | __tablename__ = "importers" 20 | 21 | id = Column(UUID, primary_key=True, default=uuid.uuid4) 22 | key = Column(UUID, unique=True, index=True, default=uuid.uuid4) 23 | name = Column(String, index=True, nullable=False) 24 | description = Column(String, nullable=True) 25 | user_id = Column(UUID, ForeignKey("users.id"), nullable=False) 26 | fields = Column(JSON, nullable=False) # JSON structure defining the importer fields 27 | 28 | # Webhook settings 29 | webhook_url = Column(String, nullable=True) # URL where imported data is sent to 30 | webhook_enabled = Column( 31 | Boolean, default=True 32 | ) # Whether to use webhook or onData callback 33 | include_data_in_webhook = Column( 34 | Boolean, default=True 35 | ) # Whether to include processed data in webhook 36 | webhook_data_sample_size = Column( 37 | Integer, default=5 38 | ) # Number of rows to include in webhook sample 39 | 40 | # Import settings 41 | include_unmatched_columns = Column( 42 | Boolean, default=False 43 | ) # Include all unmatched columns in import 44 | filter_invalid_rows = Column( 45 | Boolean, default=False 46 | ) # Filter rows that fail validation 47 | disable_on_invalid_rows = Column( 48 | Boolean, default=False 49 | ) # Disable importing all data if there are invalid rows 50 | 51 | created_at = Column(DateTime(timezone=True), server_default=func.now()) 52 | updated_at = Column(DateTime(timezone=True), onupdate=func.now()) 53 | 54 | # Relationships - using simple string references 55 | user = relationship("User", back_populates="importers") 56 | import_jobs = relationship("ImportJob", back_populates="importer") 57 | -------------------------------------------------------------------------------- /backend/app/models/token.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from sqlalchemy import ( 3 | Column, 4 | String, 5 | DateTime, 6 | ForeignKey, 7 | UUID, 8 | ) 9 | from sqlalchemy.sql import func 10 | from sqlalchemy.orm import relationship 11 | 12 | from app.db.base import Base 13 | 14 | 15 | class TokenBlacklist(Base): 16 | """ 17 | Model for tracking revoked/blacklisted tokens 18 | 19 | This model serves two purposes: 20 | 1. Track individual revoked tokens by their JTI (token_id) 21 | 2. Track mass revocation events (like 'logout from all devices') using the invalidate_before field 22 | """ 23 | 24 | __tablename__ = "token_blacklist" 25 | 26 | id = Column(UUID, primary_key=True, default=uuid.uuid4) 27 | token_id = Column( 28 | String, unique=True, index=True, nullable=False 29 | ) # JTI from the token 30 | created_at = Column(DateTime(timezone=True), server_default=func.now()) 31 | 32 | # For mass revocation: invalidate all tokens issued before this time for a specific user 33 | invalidate_before = Column(DateTime(timezone=True), nullable=True) 34 | 35 | # Link to user for easier querying 36 | user_id = Column(UUID, ForeignKey("users.id"), nullable=True) 37 | user = relationship("User", back_populates="blacklisted_tokens") 38 | -------------------------------------------------------------------------------- /backend/app/models/user.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from fastapi_users.db import SQLAlchemyBaseUserTable 4 | from sqlalchemy import Column, String, Boolean, DateTime, UUID 5 | from sqlalchemy.sql import func 6 | from sqlalchemy.orm import relationship 7 | 8 | from app.db.base import Base 9 | 10 | 11 | class User(SQLAlchemyBaseUserTable[uuid.UUID], Base): 12 | __tablename__ = "users" 13 | 14 | id = Column(UUID, primary_key=True, default=uuid.uuid4) 15 | email = Column(String, unique=True, index=True, nullable=False) 16 | hashed_password = Column(String, nullable=False) 17 | full_name = Column(String, nullable=True) 18 | is_active = Column(Boolean, default=True, nullable=False) 19 | is_superuser = Column(Boolean, default=False, nullable=False) 20 | is_verified = Column(Boolean, default=False, nullable=False) 21 | created_at = Column(DateTime(timezone=True), server_default=func.now()) 22 | updated_at = Column(DateTime(timezone=True), onupdate=func.now()) 23 | 24 | # Relationships - using simple string references 25 | importers = relationship("Importer", back_populates="user") 26 | import_jobs = relationship("ImportJob", back_populates="user") 27 | webhook_events = relationship("WebhookEvent", back_populates="user") 28 | -------------------------------------------------------------------------------- /backend/app/models/webhook.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import uuid 3 | from sqlalchemy import ( 4 | Column, 5 | Integer, 6 | JSON, 7 | DateTime, 8 | ForeignKey, 9 | Enum, 10 | Boolean, 11 | UUID, 12 | ) 13 | from sqlalchemy.sql import func 14 | from sqlalchemy.orm import relationship 15 | 16 | from app.db.base import Base 17 | 18 | 19 | class WebhookEventType(str, enum.Enum): 20 | IMPORT_STARTED = "import.started" 21 | IMPORT_VALIDATION_ERROR = "import.validation_error" 22 | IMPORT_PROGRESS = "import.progress" 23 | IMPORT_FINISHED = "import.finished" 24 | IMPORT_FAILED = "import.failed" 25 | 26 | 27 | class WebhookEvent(Base): 28 | __tablename__ = "webhook_events" 29 | 30 | id = Column(UUID, primary_key=True, default=uuid.uuid4) 31 | user_id = Column(UUID, ForeignKey("users.id"), nullable=False) 32 | import_job_id = Column(UUID, ForeignKey("import_jobs.id"), nullable=False) 33 | event_type = Column(Enum(WebhookEventType), nullable=False) 34 | payload = Column(JSON, nullable=False) 35 | delivered = Column(Boolean, default=False, nullable=False) 36 | delivery_attempts = Column(Integer, default=0, nullable=False) 37 | last_delivery_attempt = Column(DateTime(timezone=True), nullable=True) 38 | created_at = Column(DateTime(timezone=True), server_default=func.now()) 39 | 40 | # Relationships - using simple string references 41 | user = relationship("User", back_populates="webhook_events") 42 | import_job = relationship("ImportJob", back_populates="webhook_events") 43 | -------------------------------------------------------------------------------- /backend/app/schemas/auth.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, EmailStr 2 | 3 | class TokenResponse(BaseModel): 4 | """Schema for token response containing access and refresh tokens""" 5 | access_token: str 6 | refresh_token: str 7 | token_type: str 8 | 9 | 10 | class RefreshTokenRequest(BaseModel): 11 | """Schema for refresh token request""" 12 | refresh_token: str 13 | 14 | 15 | class PasswordResetRequest(BaseModel): 16 | """Schema for password reset request""" 17 | email: EmailStr 18 | -------------------------------------------------------------------------------- /backend/app/schemas/import_job.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from pydantic import BaseModel, computed_field 5 | from typing import Dict, Any, List, Optional 6 | 7 | from app.models.import_job import ImportStatus 8 | 9 | 10 | # Base ImportJob model 11 | class ImportJobBase(BaseModel): 12 | importer_id: uuid.UUID 13 | file_name: str 14 | file_type: str 15 | 16 | 17 | # ImportJob creation model 18 | class ImportJobCreate(ImportJobBase): 19 | pass 20 | 21 | 22 | # ImportJob update model 23 | class ImportJobUpdate(BaseModel): 24 | status: Optional[ImportStatus] = None 25 | processed_rows: Optional[int] = None 26 | error_count: Optional[int] = None 27 | errors: Optional[Dict[str, Any]] = None 28 | column_mapping: Optional[Dict[str, str]] = None 29 | file_metadata: Optional[Dict[str, Any]] = None 30 | 31 | 32 | # ImportJob in DB 33 | class ImportJobInDBBase(ImportJobBase): 34 | id: uuid.UUID 35 | user_id: uuid.UUID 36 | status: ImportStatus 37 | row_count: int 38 | processed_rows: int 39 | error_count: int 40 | errors: Optional[Dict[str, Any]] = None 41 | column_mapping: Optional[Dict[str, str]] = None 42 | file_metadata: Optional[Dict[str, Any]] = None 43 | created_at: datetime 44 | updated_at: Optional[datetime] = None 45 | completed_at: Optional[datetime] = None 46 | 47 | class Config: 48 | from_attributes = True 49 | 50 | 51 | # ImportJob to return via API (Uses computed_field with different names and aliases) 52 | class ImportJob(ImportJobInDBBase): 53 | 54 | @computed_field(alias="id") # Keep 'id' in JSON output using alias 55 | @property 56 | def id_str(self) -> str: # Change property name 57 | return str(self.id) 58 | 59 | @computed_field(alias="user_id") # Keep 'user_id' in JSON output 60 | @property 61 | def user_id_str(self) -> str: # Change property name 62 | return str(self.user_id) 63 | 64 | @computed_field(alias="importer_id") # Keep 'importer_id' in JSON output 65 | @property 66 | def importer_id_str(self) -> str: # Change property name 67 | return str(self.importer_id) 68 | 69 | class Config: 70 | from_attributes = True 71 | # Exclude the original UUID fields from the response if needed, 72 | # though aliasing might handle this implicitly. Let's try without exclude first. 73 | # exclude = {'id', 'user_id', 'importer_id'} 74 | 75 | 76 | # Column mapping model 77 | class ColumnMapping(BaseModel): 78 | file_column: str 79 | importer_field: str 80 | confidence: float = 0.0 81 | 82 | 83 | # Column mapping request 84 | class ColumnMappingRequest(BaseModel): 85 | mappings: List[ColumnMapping] 86 | 87 | 88 | # Import request for importer-key based authentication 89 | class ImportByKeyRequest(BaseModel): 90 | validData: List[Dict[str, Any]] 91 | invalidData: List[Dict[str, Any]] = [] 92 | columnMapping: Dict[str, Any] = {} 93 | user: Dict[str, Any] = {} 94 | metadata: Dict[str, Any] = {} 95 | importer_key: uuid.UUID 96 | 97 | 98 | # Simplified response for import processing 99 | class ImportProcessResponse(BaseModel): 100 | success: bool 101 | -------------------------------------------------------------------------------- /backend/app/schemas/schema.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from pydantic import BaseModel, Field 5 | from typing import Dict, Any, List, Optional, Literal 6 | 7 | 8 | # Schema field definition 9 | class SchemaField(BaseModel): 10 | name: str 11 | display_name: Optional[str] = None 12 | type: str # text, number, date, email, phone, boolean, select, custom_regex 13 | required: bool = False 14 | description: Optional[str] = None 15 | must_match: bool = False # Require that users must match this column 16 | not_blank: bool = False # Value cannot be blank 17 | example: Optional[str] = None # Example value for the field 18 | validation_error_message: Optional[str] = None # Custom validation error message 19 | validation_format: Optional[str] = ( 20 | None # For date format, regex pattern, or select options 21 | ) 22 | validation: Optional[Dict[str, Any]] = None # JSON Schema validation rules 23 | template: Optional[str] = ( 24 | None # Template for boolean or select fields (e.g., 'true/false', 'yes/no', '1/0') 25 | ) 26 | 27 | def dict(self, *args, **kwargs): 28 | # Ensure all fields are serializable 29 | result = super().dict(*args, **kwargs) 30 | # Remove None values to keep the JSON clean 31 | return {k: v for k, v in result.items() if v is not None} 32 | 33 | class Config: 34 | from_attributes = True 35 | 36 | 37 | # Base Schema model 38 | class SchemaBase(BaseModel): 39 | name: str 40 | description: Optional[str] = None 41 | fields: List[SchemaField] 42 | 43 | class Config: 44 | from_attributes = True 45 | 46 | 47 | # Schema creation model 48 | class SchemaCreate(SchemaBase): 49 | pass 50 | 51 | 52 | # Schema update model 53 | class SchemaUpdate(BaseModel): 54 | name: Optional[str] = None 55 | description: Optional[str] = None 56 | fields: Optional[List[SchemaField]] = None 57 | 58 | 59 | # Schema in DB 60 | class SchemaInDBBase(SchemaBase): 61 | id: uuid.UUID 62 | user_id: uuid.UUID 63 | created_at: datetime 64 | updated_at: Optional[datetime] = None 65 | 66 | class Config: 67 | from_attributes = True 68 | 69 | 70 | # Schema to return via API 71 | class Schema(SchemaInDBBase): 72 | # Convert UUID fields to strings for API responses 73 | id: str 74 | user_id: str 75 | 76 | class Config: 77 | from_attributes = True 78 | -------------------------------------------------------------------------------- /backend/app/schemas/token.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from uuid import UUID 3 | 4 | from typing import Optional 5 | from pydantic import BaseModel 6 | 7 | 8 | class TokenPayload(BaseModel): 9 | sub: str 10 | exp: int 11 | iat: int 12 | jti: str 13 | aud: list[str] 14 | 15 | 16 | class TokenData(BaseModel): 17 | # JWT standard fields 18 | sub: str 19 | exp: int 20 | iat: int 21 | jti: str 22 | aud: Optional[list[str]] = None 23 | 24 | # Convenience fields for our application 25 | user_id: Optional[UUID] = None 26 | token_id: Optional[str] = None 27 | expires_at: Optional[datetime] = None 28 | -------------------------------------------------------------------------------- /backend/app/schemas/user.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | 4 | from pydantic import BaseModel, EmailStr, Field 5 | from typing import Optional 6 | from fastapi_users.schemas import BaseUser, BaseUserCreate, BaseUserUpdate 7 | 8 | 9 | class UserRead(BaseUser[uuid.UUID]): 10 | full_name: Optional[str] = None 11 | created_at: datetime 12 | updated_at: Optional[datetime] = None 13 | 14 | class Config: 15 | from_attributes = True 16 | 17 | 18 | class UserCreate(BaseUserCreate): 19 | full_name: Optional[str] = None 20 | 21 | 22 | class UserUpdate(BaseUserUpdate): 23 | full_name: Optional[str] = None 24 | 25 | 26 | # Public registration schema 27 | class UserRegister(BaseModel): 28 | email: EmailStr 29 | password: str = Field(..., min_length=8) 30 | full_name: Optional[str] = None 31 | 32 | 33 | # For backward compatibility with existing code 34 | class User(UserRead): 35 | pass 36 | 37 | 38 | # For internal use 39 | class UserInDB(UserRead): 40 | hashed_password: str 41 | -------------------------------------------------------------------------------- /backend/app/schemas/webhook.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field, HttpUrl 2 | from typing import Dict, Any, List, Optional 3 | from datetime import datetime 4 | import uuid 5 | from app.models.webhook import WebhookEventType 6 | 7 | 8 | # Base WebhookEvent model 9 | class WebhookEventBase(BaseModel): 10 | event_type: str 11 | payload: Dict[str, Any] 12 | 13 | 14 | # WebhookEvent creation model 15 | class WebhookEventCreate(WebhookEventBase): 16 | importer_id: Optional[uuid.UUID] = None 17 | import_job_id: Optional[uuid.UUID] = None 18 | 19 | 20 | # WebhookEvent in DB 21 | class WebhookEventInDBBase(WebhookEventBase): 22 | id: uuid.UUID 23 | user_id: uuid.UUID 24 | import_job_id: uuid.UUID 25 | delivered: bool 26 | delivery_attempts: int 27 | last_delivery_attempt: Optional[datetime] = None 28 | created_at: datetime 29 | 30 | class Config: 31 | from_attributes = True 32 | 33 | 34 | # WebhookEvent to return via API 35 | class WebhookEvent(WebhookEventInDBBase): 36 | pass 37 | 38 | 39 | # Webhook configuration 40 | class WebhookConfig(BaseModel): 41 | url: HttpUrl 42 | secret: str 43 | events: List[WebhookEventType] 44 | description: Optional[str] = None 45 | active: bool = True 46 | 47 | 48 | # Webhook configuration create 49 | class WebhookConfigCreate(WebhookConfig): 50 | pass 51 | 52 | 53 | # Webhook configuration update 54 | class WebhookConfigUpdate(BaseModel): 55 | url: Optional[HttpUrl] = None 56 | secret: Optional[str] = None 57 | events: Optional[List[WebhookEventType]] = None 58 | description: Optional[str] = None 59 | active: Optional[bool] = None 60 | -------------------------------------------------------------------------------- /backend/app/services/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Modern authentication service using FastAPI-Users. 3 | """ 4 | from typing import Optional, Dict, Any 5 | from uuid import UUID 6 | 7 | from fastapi import Depends 8 | from sqlalchemy.orm import Session 9 | 10 | from app.db.base import get_db 11 | from app.auth.users import get_current_active_user 12 | from app.models.user import User 13 | 14 | 15 | async def get_user_by_id(user_id: UUID, db: Session = Depends(get_db)) -> Optional[User]: 16 | """ 17 | Get a user by ID 18 | """ 19 | return db.query(User).filter(User.id == user_id).first() 20 | 21 | 22 | async def get_user_by_email(email: str, db: Session = Depends(get_db)) -> Optional[User]: 23 | """ 24 | Get a user by email 25 | """ 26 | return db.query(User).filter(User.email == email).first() 27 | 28 | 29 | async def get_current_user_data( 30 | current_user: User = Depends(get_current_active_user), 31 | ) -> Dict[str, Any]: 32 | """ 33 | Get the current user's data 34 | """ 35 | return { 36 | "id": current_user.id, 37 | "email": current_user.email, 38 | "full_name": current_user.full_name, 39 | "is_active": current_user.is_active, 40 | "is_superuser": current_user.is_superuser, 41 | "is_verified": current_user.is_verified, 42 | } 43 | -------------------------------------------------------------------------------- /backend/app/worker.py: -------------------------------------------------------------------------------- 1 | """ 2 | Worker module for processing background jobs with RQ (Redis Queue) 3 | """ 4 | import os 5 | import sys 6 | 7 | # Set environment variable to prevent macOS fork safety issues 8 | # This is needed when running on macOS to prevent objc runtime errors 9 | os.environ['OBJC_DISABLE_INITIALIZE_FORK_SAFETY'] = 'YES' 10 | 11 | from redis import Redis 12 | from rq import Worker, Queue 13 | 14 | from app.core.config import settings 15 | 16 | # Try to import logging, but provide a fallback if not available 17 | try: 18 | from app.core.logging import setup_logging 19 | logger = setup_logging(__name__) 20 | except ImportError: 21 | import logging 22 | logger = logging.getLogger(__name__) 23 | logging.basicConfig(level=logging.INFO) 24 | 25 | # Connect to Redis 26 | redis_conn = Redis.from_url(settings.REDIS_URL) 27 | 28 | # Define queues to listen to 29 | QUEUES = [settings.RQ_IMPORT_QUEUE, 'default'] 30 | 31 | def start_worker(): 32 | """Start a worker process to listen for jobs""" 33 | logger.info(f"Starting RQ worker, listening to queues: {', '.join(QUEUES)}") 34 | logger.info(f"Using Redis at: {settings.REDIS_URL}") 35 | 36 | try: 37 | # Create queues from names 38 | queue_list = [Queue(name, connection=redis_conn) for name in QUEUES] 39 | 40 | # Create and start worker 41 | worker = Worker(queue_list, connection=redis_conn) 42 | logger.info(f"Worker started with ID: {worker.key}") 43 | worker.work(with_scheduler=True) 44 | except Exception as e: 45 | logger.error(f"Worker failed: {str(e)}") 46 | sys.exit(1) 47 | 48 | if __name__ == "__main__": 49 | start_worker() 50 | -------------------------------------------------------------------------------- /backend/app/workers/__init__.py: -------------------------------------------------------------------------------- 1 | # Import worker package 2 | -------------------------------------------------------------------------------- /backend/app/workers/import_worker.py: -------------------------------------------------------------------------------- 1 | """ 2 | Import worker module for processing CSV import jobs from the Redis Queue. 3 | 4 | This module contains worker functions that are executed by Redis Queue workers. 5 | It delegates all business logic to the ImportService class. 6 | """ 7 | 8 | import asyncio 9 | import logging 10 | from typing import Dict, Any, List 11 | 12 | from app.db.base import SessionLocal 13 | from app.services.import_service import import_service 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def process_import_job( 19 | import_job_id: str, 20 | valid_data: List[Dict[str, Any]], 21 | invalid_data: List[Dict[str, Any]] = None, 22 | ) -> Dict[str, Any]: 23 | """ 24 | Process a data import job in the background using Redis Queue. 25 | 26 | This worker function is called by RQ and delegates to the ImportService. 27 | 28 | Args: 29 | import_job_id (str): The ID of the import job 30 | valid_data (List[Dict[str, Any]]): List of valid data rows 31 | invalid_data (List[Dict[str, Any]], optional): List of invalid data rows 32 | 33 | Returns: 34 | Dict[str, Any]: Results of the import process 35 | """ 36 | logger.info(f"RQ Worker: Starting import job {import_job_id}") 37 | 38 | # Create a new database session for this worker 39 | db = SessionLocal() 40 | 41 | try: 42 | # Since we're in a worker process, we need to create a new event loop 43 | loop = asyncio.new_event_loop() 44 | asyncio.set_event_loop(loop) 45 | 46 | try: 47 | # Process the import job using the import service 48 | import_job, processed_df = loop.run_until_complete( 49 | import_service.process_import_data( 50 | db=db, 51 | import_job_id=import_job_id, 52 | valid_data=valid_data, 53 | invalid_data=invalid_data if invalid_data else [], 54 | ) 55 | ) 56 | 57 | if not import_job: 58 | return { 59 | "status": "error", 60 | "message": f"Import job {import_job_id} not found or processing failed", 61 | } 62 | 63 | logger.info(f"RQ Worker: Import job {import_job_id} completed successfully") 64 | return { 65 | "status": "success", 66 | "message": "Import completed successfully", 67 | "total_rows": import_job.row_count, 68 | "processed_rows": import_job.processed_rows, 69 | "error_count": import_job.error_count, 70 | } 71 | finally: 72 | loop.close() 73 | 74 | except Exception as e: 75 | logger.error( 76 | f"RQ Worker: Error processing import job {import_job_id}: {str(e)}", 77 | exc_info=True, 78 | ) 79 | return {"status": "error", "message": str(e)} 80 | 81 | finally: 82 | # Close the database session 83 | db.close() 84 | -------------------------------------------------------------------------------- /backend/create_migration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import subprocess 4 | from dotenv import load_dotenv 5 | 6 | # Load environment variables 7 | load_dotenv() 8 | 9 | def create_migration(message): 10 | """Create a new Alembic migration with the given message.""" 11 | try: 12 | # Generate the migration 13 | result = subprocess.run( 14 | ["alembic", "revision", "--autogenerate", "-m", message], 15 | check=True, 16 | capture_output=True, 17 | text=True 18 | ) 19 | print(result.stdout) 20 | print("Migration file created successfully!") 21 | except subprocess.CalledProcessError as e: 22 | print(f"Error creating migration: {e}") 23 | print(f"Error output: {e.stderr}") 24 | sys.exit(1) 25 | 26 | def apply_migrations(): 27 | """Apply all pending migrations.""" 28 | try: 29 | # Apply the migrations 30 | result = subprocess.run( 31 | ["alembic", "upgrade", "head"], 32 | check=True, 33 | capture_output=True, 34 | text=True 35 | ) 36 | print(result.stdout) 37 | print("Migrations applied successfully!") 38 | except subprocess.CalledProcessError as e: 39 | print(f"Error applying migrations: {e}") 40 | print(f"Error output: {e.stderr}") 41 | sys.exit(1) 42 | 43 | if __name__ == "__main__": 44 | if len(sys.argv) < 2: 45 | print("Usage: python create_migration.py [--apply]") 46 | sys.exit(1) 47 | 48 | message = sys.argv[1] 49 | create_migration(message) 50 | 51 | # Check if --apply flag is provided 52 | if len(sys.argv) > 2 and sys.argv[2] == "--apply": 53 | apply_migrations() 54 | -------------------------------------------------------------------------------- /backend/init_app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | from app.db.init_db import init_db_and_create_superuser 4 | from dotenv import load_dotenv 5 | 6 | # Load environment variables 7 | load_dotenv() 8 | 9 | async def init(): 10 | # Initialize database and create superuser 11 | await init_db_and_create_superuser() 12 | print("Database initialization completed") 13 | 14 | if __name__ == "__main__": 15 | asyncio.run(init()) 16 | -------------------------------------------------------------------------------- /backend/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /backend/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | import os 3 | import sys 4 | from dotenv import load_dotenv 5 | 6 | # Add the parent directory to sys.path 7 | sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) 8 | 9 | from sqlalchemy import engine_from_config 10 | from sqlalchemy import pool 11 | 12 | from alembic import context 13 | 14 | # Load environment variables from .env file 15 | load_dotenv() 16 | 17 | # Import models to ensure they're registered with the Base metadata 18 | from app.models.user import User 19 | from app.models.importer import Importer 20 | from app.models.import_job import ImportJob 21 | from app.models.webhook import WebhookEvent 22 | from app.db.base import Base 23 | from app.core.config import settings 24 | 25 | # this is the Alembic Config object, which provides 26 | # access to the values within the .ini file in use. 27 | config = context.config 28 | 29 | # Set the SQLAlchemy URL from settings 30 | config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) 31 | 32 | # Interpret the config file for Python logging. 33 | # This line sets up loggers basically. 34 | if config.config_file_name is not None: 35 | fileConfig(config.config_file_name) 36 | 37 | # add your model's MetaData object here 38 | # for 'autogenerate' support 39 | target_metadata = Base.metadata 40 | 41 | 42 | def run_migrations_offline() -> None: 43 | """Run migrations in 'offline' mode. 44 | 45 | This configures the context with just a URL 46 | and not an Engine, though an Engine is acceptable 47 | here as well. By skipping the Engine creation 48 | we don't even need a DBAPI to be available. 49 | 50 | Calls to context.execute() here emit the given string to the 51 | script output. 52 | 53 | """ 54 | url = config.get_main_option("sqlalchemy.url") 55 | context.configure( 56 | url=url, 57 | target_metadata=target_metadata, 58 | literal_binds=True, 59 | dialect_opts={"paramstyle": "named"}, 60 | ) 61 | 62 | with context.begin_transaction(): 63 | context.run_migrations() 64 | 65 | 66 | def run_migrations_online() -> None: 67 | """Run migrations in 'online' mode. 68 | 69 | In this scenario we need to create an Engine 70 | and associate a connection with the context. 71 | 72 | """ 73 | connectable = engine_from_config( 74 | config.get_section(config.config_ini_section, {}), 75 | prefix="sqlalchemy.", 76 | poolclass=pool.NullPool, 77 | ) 78 | 79 | with connectable.connect() as connection: 80 | context.configure( 81 | connection=connection, target_metadata=target_metadata 82 | ) 83 | 84 | with context.begin_transaction(): 85 | context.run_migrations() 86 | 87 | 88 | if context.is_offline_mode(): 89 | run_migrations_offline() 90 | else: 91 | run_migrations_online() 92 | -------------------------------------------------------------------------------- /backend/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | """Upgrade schema.""" 23 | ${upgrades if upgrades else "pass"} 24 | 25 | 26 | def downgrade() -> None: 27 | """Downgrade schema.""" 28 | ${downgrades if downgrades else "pass"} 29 | -------------------------------------------------------------------------------- /backend/migrations/versions/98f92bc3f715_add_webhook_data_fields.py: -------------------------------------------------------------------------------- 1 | """add_webhook_data_fields 2 | 3 | Revision ID: 98f92bc3f715 4 | Revises: 34203d60870b 5 | Create Date: 2025-04-24 15:13:23.547319 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '98f92bc3f715' 16 | down_revision: Union[str, None] = '34203d60870b' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | """Upgrade schema.""" 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.add_column('importers', sa.Column('include_data_in_webhook', sa.Boolean(), nullable=True)) 25 | op.add_column('importers', sa.Column('webhook_data_sample_size', sa.Integer(), nullable=True)) 26 | op.alter_column('importers', 'webhook_url', 27 | existing_type=sa.VARCHAR(), 28 | nullable=True) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade() -> None: 33 | """Downgrade schema.""" 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.alter_column('importers', 'webhook_url', 36 | existing_type=sa.VARCHAR(), 37 | nullable=False) 38 | op.drop_column('importers', 'webhook_data_sample_size') 39 | op.drop_column('importers', 'include_data_in_webhook') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /backend/migrations/versions/add_token_blacklist.py: -------------------------------------------------------------------------------- 1 | """Add token blacklist table 2 | 3 | Revision ID: add_token_blacklist 4 | Revises: 5 | Create Date: 2025-04-24 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import postgresql 11 | import uuid 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = 'add_token_blacklist' 16 | down_revision = '98f92bc3f715' # Updated to depend on the previous migration 17 | branch_labels = None 18 | depends_on = None 19 | 20 | 21 | def upgrade(): 22 | # Check if token_blacklist table exists 23 | conn = op.get_bind() 24 | inspector = sa.inspect(conn) 25 | tables = inspector.get_table_names() 26 | 27 | if 'token_blacklist' not in tables: 28 | # Create token_blacklist table 29 | op.create_table('token_blacklist', 30 | sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, default=uuid.uuid4), 31 | sa.Column('token_id', sa.String(), nullable=False, index=True, unique=True), 32 | sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), 33 | sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=True), 34 | ) 35 | 36 | # Check if index exists before creating it 37 | indexes = inspector.get_indexes('token_blacklist') 38 | index_names = [index['name'] for index in indexes] 39 | 40 | if 'ix_token_blacklist_token_id' not in index_names: 41 | # Create index on token_id for faster lookups 42 | op.create_index(op.f('ix_token_blacklist_token_id'), 'token_blacklist', ['token_id'], unique=True) 43 | else: 44 | print("Table 'token_blacklist' already exists, skipping creation") 45 | 46 | 47 | def downgrade(): 48 | # Check if token_blacklist table exists before dropping 49 | conn = op.get_bind() 50 | inspector = sa.inspect(conn) 51 | tables = inspector.get_table_names() 52 | 53 | if 'token_blacklist' in tables: 54 | # Check if index exists before dropping it 55 | indexes = inspector.get_indexes('token_blacklist') 56 | index_names = [index['name'] for index in indexes] 57 | 58 | if 'ix_token_blacklist_token_id' in index_names: 59 | # Drop index 60 | op.drop_index(op.f('ix_token_blacklist_token_id'), table_name='token_blacklist') 61 | 62 | # Drop table 63 | op.drop_table('token_blacklist') 64 | -------------------------------------------------------------------------------- /backend/migrations/versions/c734150f184e_add_key_to_importer.py: -------------------------------------------------------------------------------- 1 | """Add key to importer 2 | 3 | Revision ID: c734150f184e 4 | Revises: add_token_blacklist 5 | Create Date: 2025-04-26 13:51:24.378207 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | from sqlalchemy.dialects import postgresql 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = 'c734150f184e' 16 | down_revision: Union[str, None] = 'add_token_blacklist' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | """Upgrade schema.""" 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.drop_index('ix_token_blacklist_token_id', table_name='token_blacklist') 25 | op.drop_table('token_blacklist') 26 | op.add_column('importers', sa.Column('key', sa.UUID(), nullable=True)) 27 | op.create_index(op.f('ix_importers_key'), 'importers', ['key'], unique=True) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade() -> None: 32 | """Downgrade schema.""" 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_index(op.f('ix_importers_key'), table_name='importers') 35 | op.drop_column('importers', 'key') 36 | op.create_table('token_blacklist', 37 | sa.Column('id', sa.UUID(), autoincrement=False, nullable=False), 38 | sa.Column('token_id', sa.VARCHAR(), autoincrement=False, nullable=False), 39 | sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), 40 | sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True), 41 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='token_blacklist_user_id_fkey', ondelete='CASCADE'), 42 | sa.PrimaryKeyConstraint('id', name='token_blacklist_pkey') 43 | ) 44 | op.create_index('ix_token_blacklist_token_id', 'token_blacklist', ['token_id'], unique=True) 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /backend/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | asyncio_mode = auto 7 | markers = 8 | asyncio: mark a test as an asyncio coroutine 9 | slow: mark test as slow 10 | integration: mark as integration test 11 | unit: mark as unit test 12 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohappyeyeballs==2.6.1 2 | aiohttp==3.11.16 3 | aiosignal==1.3.2 4 | alembic==1.15.2 5 | annotated-types==0.7.0 6 | anyio==4.9.0 7 | argon2-cffi==23.1.0 8 | argon2-cffi-bindings==21.2.0 9 | asgi-lifespan==2.1.0 10 | asttokens==3.0.0 11 | asyncpg==0.30.0 12 | attrs==25.3.0 13 | bcrypt==4.3.0 14 | certifi==2025.1.31 15 | cffi==1.17.1 16 | charset-normalizer==3.4.1 17 | click==8.1.8 18 | cryptography==44.0.2 19 | decorator==5.2.1 20 | distro==1.9.0 21 | dnspython==2.7.0 22 | ecdsa==0.19.1 23 | email_validator==2.2.0 24 | et_xmlfile==2.0.0 25 | executing==2.2.0 26 | fastapi==0.115.12 27 | fastapi-users==14.0.1 28 | fastapi-users-db-sqlalchemy==7.0.0 29 | filelock==3.18.0 30 | frozenlist==1.5.0 31 | fsspec==2025.3.2 32 | greenlet==3.2.0 33 | h11==0.14.0 34 | httpcore==1.0.8 35 | httpx==0.28.1 36 | huggingface-hub==0.30.2 37 | idna==3.10 38 | importlib_metadata==8.6.1 39 | iniconfig==2.1.0 40 | ipdb==0.13.13 41 | ipython==9.1.0 42 | ipython_pygments_lexers==1.1.1 43 | itsdangerous==2.2.0 44 | jedi==0.19.2 45 | Jinja2==3.1.6 46 | jiter==0.9.0 47 | jsonschema==4.23.0 48 | jsonschema-specifications==2024.10.1 49 | 50 | makefun==1.15.6 51 | Mako==1.3.10 52 | MarkupSafe==3.0.2 53 | matplotlib-inline==0.1.7 54 | multidict==6.4.3 55 | numpy==2.2.4 56 | 57 | openpyxl==3.1.5 58 | packaging==24.2 59 | pandas==2.2.3 60 | parso==0.8.4 61 | passlib==1.7.4 62 | pexpect==4.9.0 63 | pluggy==1.5.0 64 | prompt_toolkit==3.0.51 65 | propcache==0.3.1 66 | psycopg2-binary==2.9.10 67 | ptyprocess==0.7.0 68 | pure_eval==0.2.3 69 | pwdlib==0.2.1 70 | pyasn1==0.4.8 71 | pycparser==2.22 72 | pydantic==2.11.3 73 | pydantic-settings==2.8.1 74 | pydantic_core==2.33.1 75 | Pygments==2.19.1 76 | PyJWT==2.10.1 77 | pytest==8.3.5 78 | pytest-asyncio==0.26.0 79 | python-dateutil==2.9.0.post0 80 | python-dotenv==1.1.0 81 | python-jose==3.4.0 82 | python-multipart==0.0.20 83 | pytz==2025.2 84 | PyYAML==6.0.2 85 | redis==5.2.1 86 | referencing==0.36.2 87 | regex==2024.11.6 88 | requests==2.32.3 89 | rpds-py==0.24.0 90 | rq==2.3.2 91 | rsa==4.9 92 | six==1.17.0 93 | sniffio==1.3.1 94 | SQLAlchemy==2.0.40 95 | stack-data==0.6.3 96 | starlette==0.46.2 97 | 98 | tokenizers==0.21.1 99 | tqdm==4.67.1 100 | traitlets==5.14.3 101 | typing-inspection==0.4.0 102 | typing_extensions==4.13.2 103 | tzdata==2025.2 104 | urllib3==2.4.0 105 | uvicorn==0.34.1 106 | wcwidth==0.2.13 107 | yarl==1.19.0 108 | zipp==3.21.0 109 | -------------------------------------------------------------------------------- /backend/scripts/reset_password.py: -------------------------------------------------------------------------------- 1 | # scripts/reset_password.py 2 | import sys 3 | import os 4 | import asyncio 5 | from sqlalchemy import create_engine 6 | from sqlalchemy.orm import sessionmaker 7 | from fastapi_users.password import PasswordHelper 8 | 9 | # Adjust the path to import from the app directory 10 | backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) 11 | sys.path.append(backend_dir) 12 | 13 | from app.core.config import settings 14 | from app.models.user import User # Adjust import path if necessary 15 | from app.models.schema import Schema # Add import for Schema model 16 | from app.models.import_job import ImportJob # Add import for ImportJob model 17 | from app.models.webhook import WebhookEvent # Add import for WebhookEvent model 18 | 19 | # Create a password helper from FastAPI-Users 20 | password_helper = PasswordHelper() 21 | 22 | DATABASE_URL = settings.DATABASE_URL 23 | engine = create_engine(DATABASE_URL) 24 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) 25 | 26 | def reset_user_password(email: str, new_password: str): 27 | db = SessionLocal() 28 | try: 29 | user = db.query(User).filter(User.email == email).first() 30 | if not user: 31 | print(f"Error: User with email {email} not found.") 32 | return 33 | 34 | # Use FastAPI-Users' password helper for hashing 35 | hashed_password = password_helper.hash(new_password) 36 | user.hashed_password = hashed_password 37 | user.is_active = True # Ensure the user is active 38 | db.add(user) 39 | db.commit() 40 | print(f"Password for user {email} has been reset and user activated successfully.") 41 | 42 | except Exception as e: 43 | db.rollback() 44 | print(f"An error occurred: {e}") 45 | finally: 46 | db.close() 47 | 48 | if __name__ == "__main__": 49 | if len(sys.argv) != 3: 50 | print("Usage: python scripts/reset_password.py ") 51 | sys.exit(1) 52 | 53 | user_email = sys.argv[1] 54 | new_password = sys.argv[2] 55 | 56 | reset_user_password(user_email, new_password) 57 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | postgres: 5 | image: postgres:16 6 | container_name: importcsv-postgres 7 | environment: 8 | POSTGRES_USER: postgres 9 | POSTGRES_PASSWORD: postgres 10 | POSTGRES_DB: importcsv 11 | ports: 12 | - "5432:5432" 13 | volumes: 14 | - postgres_data:/var/lib/postgresql/data 15 | restart: unless-stopped 16 | 17 | redis: 18 | image: redis:7-alpine 19 | container_name: importcsv-redis 20 | ports: 21 | - "6379:6379" 22 | volumes: 23 | - redis_data:/data 24 | restart: unless-stopped 25 | command: redis-server --appendonly yes 26 | 27 | backend: 28 | build: 29 | context: ./backend 30 | dockerfile: Dockerfile 31 | container_name: importcsv-backend 32 | depends_on: 33 | - postgres 34 | - redis 35 | ports: 36 | - "8000:8000" 37 | environment: 38 | - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/importcsv 39 | - REDIS_URL=redis://redis:6379/0 40 | - ENVIRONMENT=development 41 | - SECRET_KEY=your-secret-key-at-least-32-characters-long 42 | - WEBHOOK_SECRET=your-webhook-secret-for-callbacks 43 | volumes: 44 | - ./backend:/app 45 | - ./backend/uploads:/app/uploads 46 | - backend_logs:/app/logs 47 | restart: unless-stopped 48 | command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload 49 | 50 | worker: 51 | build: 52 | context: ./backend 53 | dockerfile: Dockerfile 54 | container_name: importcsv-worker 55 | depends_on: 56 | - postgres 57 | - redis 58 | - backend 59 | environment: 60 | - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/importcsv 61 | - REDIS_URL=redis://redis:6379/0 62 | - ENVIRONMENT=development 63 | - SECRET_KEY=your-secret-key-at-least-32-characters-long 64 | - WEBHOOK_SECRET=your-webhook-secret-for-callbacks 65 | volumes: 66 | - ./backend:/app 67 | - ./backend/uploads:/app/uploads 68 | restart: unless-stopped 69 | command: python -m app.worker 70 | 71 | admin: 72 | build: 73 | context: . 74 | dockerfile: admin/Dockerfile 75 | container_name: importcsv-admin 76 | depends_on: 77 | - backend 78 | ports: 79 | - "3000:3000" 80 | environment: 81 | - NEXT_PUBLIC_API_URL=http://backend:8000 82 | volumes: 83 | - ./admin/src:/app/src 84 | - ./admin/public:/app/public 85 | - /app/node_modules 86 | - /app/.next 87 | restart: unless-stopped 88 | 89 | volumes: 90 | postgres_data: 91 | redis_data: 92 | backend_logs: 93 | -------------------------------------------------------------------------------- /docs/assets/demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishekray07/importcsv/205669cc0d0bf442c5b2cacd22ef56bc3f2ec457/docs/assets/demo.mp4 -------------------------------------------------------------------------------- /docs/assets/importer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishekray07/importcsv/205669cc0d0bf442c5b2cacd22ef56bc3f2ec457/docs/assets/importer.png -------------------------------------------------------------------------------- /docs/assets/mapping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishekray07/importcsv/205669cc0d0bf442c5b2cacd22ef56bc3f2ec457/docs/assets/mapping.png -------------------------------------------------------------------------------- /docs/assets/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishekray07/importcsv/205669cc0d0bf442c5b2cacd22ef56bc3f2ec457/docs/assets/schema.png -------------------------------------------------------------------------------- /docs/assets/validation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishekray07/importcsv/205669cc0d0bf442c5b2cacd22ef56bc3f2ec457/docs/assets/validation.png -------------------------------------------------------------------------------- /docs/assets/webhooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abhishekray07/importcsv/205669cc0d0bf442c5b2cacd22ef56bc3f2ec457/docs/assets/webhooks.png -------------------------------------------------------------------------------- /frontend/.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceType": "unambiguous", 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "chrome": 100 9 | } 10 | } 11 | ], 12 | "@babel/preset-typescript", 13 | "@babel/preset-react" 14 | ], 15 | "plugins": [] 16 | } 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.iml 3 | *.swp 4 | *.swo 5 | *.dll 6 | *.so 7 | *.dylib 8 | *.test 9 | *.out 10 | *.log 11 | *.env 12 | *.zip 13 | 14 | # debug 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | node_modules/ 20 | vendor/ 21 | build/ 22 | .yarn/ 23 | .pnp* 24 | 25 | .DS_Store 26 | .idea 27 | .vscode 28 | 29 | /coverage 30 | /.next/ 31 | /out/ 32 | 33 | # misc 34 | *.pem 35 | 36 | # local env files 37 | .env*.local 38 | 39 | # vercel 40 | .vercel 41 | 42 | # typescript 43 | *.tsbuildinfo 44 | next-env.d.ts 45 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "tabWidth": 2, 5 | "jsxSingleQuote": false, 6 | "bracketSameLine": true, 7 | "printWidth": 150, 8 | "endOfLine": "auto", 9 | "importOrder": [ 10 | "^[^/.]+$", 11 | "^@.+$", 12 | "^\\.\\./(.*)/[A-Z]((?!\\.)(?!types.).)+$", 13 | "^\\.\\./(.*)/((?!\\.)(?!types.).)+$", 14 | "^\\.$", 15 | "^\\./(.*)/[A-Z]((?!\\.)(?!types.).)+$", 16 | "^\\./(.*)/((?!\\.)(?!types.).)+$", 17 | "/types$", 18 | "\\.(css|scss)$", 19 | "\\.(svg|png|jpg|jpeg|gif)$", 20 | ".*" 21 | ], 22 | "importOrderSeparation": false, 23 | "importOrderSortSpecifiers": true, 24 | "importOrderCaseInsensitive": true 25 | } 26 | -------------------------------------------------------------------------------- /frontend/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /frontend/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Portola Labs, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'tailwindcss/nesting': {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /frontend/rollup.config-js.js: -------------------------------------------------------------------------------- 1 | import peerDepsExternal from "rollup-plugin-peer-deps-external"; 2 | 3 | import postcss from "rollup-plugin-postcss"; 4 | import typescript from "rollup-plugin-typescript2"; 5 | import commonjs from "@rollup/plugin-commonjs"; 6 | import image from "@rollup/plugin-image"; 7 | import json from "@rollup/plugin-json"; 8 | import resolve, { nodeResolve } from "@rollup/plugin-node-resolve"; 9 | import replace from "@rollup/plugin-replace"; 10 | 11 | const packageJson = require("./package.json"); 12 | 13 | export default { 14 | input: "src/js.tsx", 15 | output: [ 16 | { 17 | file: "build/index.js", 18 | format: "umd", 19 | name: "CSVImporter", 20 | sourcemap: true, 21 | }, 22 | ], 23 | plugins: [ 24 | replace({ 25 | "process.env.NODE_ENV": JSON.stringify("production"), 26 | preventAssignment: true, 27 | }), 28 | resolve({ 29 | browser: true, 30 | }), 31 | commonjs(), 32 | typescript({ useTsconfigDeclarationDir: true }), 33 | image(), 34 | postcss({}), 35 | json(), 36 | ], 37 | }; -------------------------------------------------------------------------------- /frontend/rollup.config.js: -------------------------------------------------------------------------------- 1 | import peerDepsExternal from "rollup-plugin-peer-deps-external"; 2 | import postcss from "rollup-plugin-postcss"; 3 | import typescript from "rollup-plugin-typescript2"; 4 | import commonjs from "@rollup/plugin-commonjs"; 5 | import image from "@rollup/plugin-image"; 6 | import json from "@rollup/plugin-json"; 7 | import resolve from "@rollup/plugin-node-resolve"; 8 | 9 | const packageJson = require("./package.json"); 10 | 11 | export default { 12 | input: "src/index.ts", 13 | output: [ 14 | { 15 | file: packageJson.main, 16 | format: "cjs", 17 | sourcemap: true, 18 | }, 19 | { 20 | file: packageJson.module, 21 | format: "esm", 22 | sourcemap: true, 23 | }, 24 | ], 25 | external: ["react", "react-dom", "react/jsx-runtime", "@emotion/react"], 26 | plugins: [ 27 | peerDepsExternal(), 28 | resolve({ 29 | browser: true, 30 | }), 31 | commonjs(), 32 | typescript({ useTsconfigDeclarationDir: true }), 33 | image(), 34 | postcss({}), 35 | json(), 36 | ], 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/components/CSVImporter/style/csv-importer.css: -------------------------------------------------------------------------------- 1 | .CSVImporter { 2 | border: none; 3 | background-color: transparent; 4 | padding: 0 1rem; 5 | border-radius: 1.2rem; 6 | color: inherit; 7 | cursor: pointer; 8 | font-weight: 500; 9 | font-size: 14px; 10 | /* height: 2.4rem; */ 11 | display: inline-flex; 12 | gap: 0.5rem; 13 | align-items: center; 14 | transition: filter 0.2s ease-out; 15 | } 16 | 17 | .CSVImporter svg { 18 | display: block; 19 | } 20 | 21 | .CSVImporter svg path { 22 | stroke: currentColor !important; 23 | } 24 | 25 | .CSVImporter:hover, 26 | .CSVImporter:active { 27 | filter: brightness(1.2); 28 | } 29 | 30 | .CSVImporter-dialog::backdrop { 31 | background-color: rgba(0, 0, 0, 0.85); 32 | } 33 | 34 | .CSVImporter-dialog { 35 | border-radius: 1rem; 36 | width: 80vw; 37 | max-height: 80vh; 38 | min-width: 907px; 39 | border: none; 40 | position: fixed; 41 | inset: 0; 42 | padding: 0; 43 | margin: auto; 44 | } 45 | 46 | .CSVImporter-div { 47 | border: none; 48 | display: block; 49 | width: 100%; 50 | max-height: 600px; 51 | overflow-y: auto; 52 | } 53 | 54 | @media (max-width: 768px) { 55 | .CSVImporter-dialog { 56 | width: 90vw; 57 | min-width: 950px; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/i18n/de.ts: -------------------------------------------------------------------------------- 1 | const translations = { 2 | Upload: "Hochladen", 3 | "Select Header": "Kopfzeile auswählen", 4 | "Map Columns": "Spalten zuordnen", 5 | "Expected Column": "Erwartete Spalten", 6 | Required: "Erforderlich", 7 | "Drop your file here": "Datei hier ablegen", 8 | or: "oder", 9 | "Browse files": "Dateien durchsuchen", 10 | "Download Template": "Vorlage herunterladen", 11 | "Only CSV, XLS, and XLSX files can be uploaded": "Nur CSV-, XLS- und XLSX-Dateien können hochgeladen werden", 12 | "Select Header Row": "Kopfzeilenreihe auswählen", 13 | "Select the row which contains the column headers": "Wähle die Zeile, die die Spaltenüberschriften enthält", 14 | "Only the first sheet ("{{sheet}}") of the Excel file will be imported. To import multiple sheets, please upload each sheet individually.": "Nur das erste Blatt ("{{sheet}}") der Excel-Datei wird importiert. Um mehrere Blätter zu importieren, lade bitte jedes Blatt einzeln hoch.", 15 | "Cancel": "Abbrechen", 16 | "Continue": "Weiter", 17 | "Your File Column": "Deine Spalte der Datei", 18 | "Your Sample Data": "Deine Beispieldaten", 19 | "Destination Column": "Zielspalte", 20 | "Include": "Einfügen", 21 | "Submit": "Senden", 22 | "Loading...": "Laden...", 23 | "Please include all required columns": "Bitte alle erforderlichen Spalten einfügen", 24 | "Back": "Zurück", 25 | "- Select one -": "- Wähle eine aus -", 26 | "- empty -": "- leer -", 27 | "Import Successful": "Import erfolgreich", 28 | "Upload Successful": "Upload erfolgreich", 29 | "Done": "Fertig", 30 | "Upload another file": "Eine weitere Datei hochladen", 31 | }; 32 | 33 | export default translations; 34 | -------------------------------------------------------------------------------- /frontend/src/i18n/es.ts: -------------------------------------------------------------------------------- 1 | const translations = { 2 | Upload: "Subir", 3 | "Select Header": "Seleccionar encabezado", 4 | "Map Columns": "Mapear columnas", 5 | "Expected Column": "Columnas esperadas", 6 | Required: "Requerido", 7 | "Drop your file here": "Suelta tu archivo aquí", 8 | or: "o", 9 | "Browse files": "Examinar archivos", 10 | "Download Template": "Descargar plantilla", 11 | "Only CSV, XLS, and XLSX files can be uploaded": "Solo se pueden subir archivos CSV, XLS y XLSX", 12 | "Select Header Row": "Seleccionar fila de encabezado", 13 | "Select the row which contains the column headers": "Selecciona la fila que contiene los encabezados de las columnas", 14 | "Only the first sheet ("{{sheet}}") of the Excel file will be imported. To import multiple sheets, please upload each sheet individually.": " Solo se importará la primera hoja ("{{sheet}}") del archivo de Excel. Para importar varias hojas, sube cada hoja individualmente.", 15 | "Cancel": "Cancelar", 16 | "Continue": "Continuar", 17 | "Your File Column": "Tu columna del archivo", 18 | "Your Sample Data": "Tus datos de muestra", 19 | "Destination Column": "Columna de destino", 20 | "Include": "Incluir", 21 | "Submit": "Enviar", 22 | "Loading...": "Cargando...", 23 | "Please include all required columns": "Por favor incluye todas las columnas requeridas", 24 | "Back": "Atrás", 25 | "- Select one -": "- Selecciona uno -", 26 | "- empty -": "- vacío -", 27 | "Import Successful": "Importación exitosa", 28 | "Upload Successful": "Se ha subido con éxito", 29 | "Done": "Listo", 30 | "Upload another file": "Subir otro archivo", 31 | }; 32 | 33 | export default translations; 34 | -------------------------------------------------------------------------------- /frontend/src/i18n/fr.ts: -------------------------------------------------------------------------------- 1 | // Translations in french 2 | //TODO: Double the translations 3 | const translations = { 4 | Upload: "Télécharger", 5 | "Select Header": "Sélectionner l'en-tête", 6 | "Map Columns": "Mapper les colonnes", 7 | "Expected Column": "Colonne attendue", 8 | Required: "Requis", 9 | "Drop your file here": "Déposez votre fichier ici", 10 | or: "ou", 11 | "Browse files": "Parcourir les fichiers", 12 | "Download Template": "Télécharger le modèle", 13 | "Only CSV, XLS, and XLSX files can be uploaded": "Seuls les fichiers CSV, XLS et XLSX peuvent être téléchargés", 14 | "Select Header Row": "Sélectionner la ligne d'en-tête", 15 | "Select the row which contains the column headers": "Sélectionnez la ligne qui contient les en-têtes de colonne", 16 | "Only the first sheet ("{{sheet}}") of the Excel file will be imported. To import multiple sheets, please upload each sheet individually.": "Seule la première feuille ("{{sheet}}") du fichier Excel sera importée. Pour importer plusieurs feuilles, veuillez télécharger chaque feuille individuellement.", 17 | "Cancel": "Annuler", 18 | "Continue": "Continuer", 19 | "Your File Column": "Votre colonne de fichier", 20 | "Your Sample Data": "Vos données d'échantillon", 21 | "Destination Column": "Colonne de destination", 22 | "Include": "Inclure", 23 | "Submit": "Soumettre", 24 | "Loading...": "Chargement...", 25 | "Please include all required columns": "Veuillez inclure toutes les colonnes requises", 26 | "Back": "Retour", 27 | "- Select one -": "- Sélectionnez un -", 28 | "- empty -": "- vide -", 29 | "Import Successful": "Importation réussie", 30 | "Upload Successful": "Téléchargement réussi", 31 | "Done": "Terminé", 32 | "Upload another file": "Télécharger un autre fichier", 33 | }; 34 | 35 | export default translations; 36 | -------------------------------------------------------------------------------- /frontend/src/i18n/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18, { Resource } from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | import esTranslation from "./es"; 4 | import frTranslation from "./fr"; 5 | import itTranslations from "./it"; 6 | import deTranslations from "./de"; 7 | 8 | const resources: Resource = { 9 | en: { 10 | translation: {}, 11 | }, 12 | fr: { 13 | translation: frTranslation, 14 | }, 15 | es: { 16 | translation: esTranslation, 17 | }, 18 | it: { 19 | translation: itTranslations, 20 | }, 21 | de: { 22 | translation: deTranslations, 23 | }, 24 | }; 25 | 26 | i18.use(initReactI18next).init({ 27 | resources, 28 | lng: "en", 29 | keySeparator: false, 30 | interpolation: { 31 | escapeValue: false, 32 | }, 33 | }); 34 | 35 | export default i18; 36 | -------------------------------------------------------------------------------- /frontend/src/i18n/it.ts: -------------------------------------------------------------------------------- 1 | // Translations in Italian 2 | const translations = { 3 | Upload: "Caricare", 4 | "Select Header": "Seleziona intestazione", 5 | "Map Columns": "Mappa colonne", 6 | "Expected Column": "Colonna prevista", 7 | Required: "Richiesto", 8 | "Drop your file here": "Trascina il tuo file qui", 9 | or: "oppure", 10 | "Browse files": "Sfoglia file", 11 | "Download Template": "Scarica il modello", 12 | "Only CSV, XLS, and XLSX files can be uploaded": "Solo i file CSV, XLS e XLSX possono essere caricati", 13 | "Select Header Row": "Seleziona la riga di intestazione", 14 | "Select the row which contains the column headers": "Seleziona la riga che contiene le intestazioni delle colonne", 15 | "Only the first sheet ("{{sheet}}") of the Excel file will be imported. To import multiple sheets, please upload each sheet individually.": "Solo il primo foglio ("{{sheet}}") del file Excel verrà importato. Per importare più fogli, carica ogni foglio singolarmente.", 16 | "Cancel": "Annulla", 17 | "Continue": "Continua", 18 | "Your File Column": "La tua colonna di file", 19 | "Your Sample Data": "I tuoi dati di esempio", 20 | "Destination Column": "Colonna di destinazione", 21 | "Include": "Includere", 22 | "Submit": "Invia", 23 | "Loading...": "Caricamento...", 24 | "Please include all required columns": "Si prega di includere tutte le colonne richieste", 25 | "Back": "Indietro", 26 | "- Select one -": "- Selezionane uno -", 27 | "- empty -": "- vuoto -", 28 | "Import Successful": "Importazione riuscita", 29 | "Upload Successful": "Caricamento riuscito", 30 | "Done": "Fatto", 31 | "Upload another file": "Carica un altro file", 32 | }; 33 | 34 | export default translations; 35 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Box/index.tsx: -------------------------------------------------------------------------------- 1 | import classes from "../../utils/classes"; 2 | import { BoxProps } from "./types"; 3 | import style from "./style/Box.module.scss"; 4 | 5 | export default function Box({ className, variants = [], ...props }: BoxProps) { 6 | const variantStyles = classes(variants.map((c: keyof typeof style) => style[c])); 7 | const containerClasses = classes([style.box, variantStyles, className]); 8 | 9 | return
; 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Box/style/Box.module.scss: -------------------------------------------------------------------------------- 1 | .box { 2 | display: block; 3 | margin: 0 auto; 4 | padding: var(--m); 5 | background-color: var(--color-background-modal); 6 | border-radius: var(--border-radius-5); 7 | box-shadow: 0 0 20px var(--color-background-modal-shadow); 8 | max-width: 100%; 9 | 10 | &.fluid { 11 | max-width: none; 12 | } 13 | &.mid { 14 | max-width: 440px; 15 | } 16 | &.wide { 17 | max-width: 660px; 18 | } 19 | &.space-l { 20 | padding: var(--m-l); 21 | } 22 | &.space-mid { 23 | padding: var(--m); 24 | } 25 | &.space-none { 26 | padding: 0; 27 | } 28 | &.bg-shade { 29 | background-color: var(--color-background-modal-shade); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Box/types/index.ts: -------------------------------------------------------------------------------- 1 | export type BoxVariant = "fluid" | "mid" | "wide" | "space-l" | "space-mid" | "space-none" | "bg-shade"; 2 | 3 | export type BoxProps = React.HTMLAttributes & { 4 | variants?: BoxVariant[]; 5 | }; 6 | 7 | export const boxVariants: BoxVariant[] = ["fluid", "mid", "wide", "space-l", "space-mid", "space-none", "bg-shade"]; 8 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import classes from "../../utils/classes"; 2 | import { CheckboxProps } from "./types"; 3 | import style from "./style/Checkbox.module.scss"; 4 | 5 | export default function Checkbox({ label, className, ...props }: CheckboxProps) { 6 | const containerClasses = classes([style.container, className]); 7 | 8 | return ( 9 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Checkbox/style/Checkbox.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: inline-block; 3 | gap: var(--m-xs); 4 | align-items: center; 5 | 6 | &:has(input:not(:disabled)) { 7 | cursor: pointer; 8 | } 9 | 10 | input[type="checkbox"] { 11 | -webkit-appearance: none; 12 | appearance: none; 13 | background-color: transparent; 14 | margin: 0; 15 | color: var(--color-primary); 16 | width: var(--m); 17 | height: var(--m); 18 | border: 2px solid var(--color-border); 19 | display: grid; 20 | place-content: center; 21 | border-radius: var(--border-radius-1); 22 | cursor: pointer; 23 | 24 | &::before { 25 | content: ""; 26 | width: var(--m-xs); 27 | height: var(--m-xs); 28 | } 29 | 30 | &:checked { 31 | background-color: var(--color-primary); 32 | border-color: var(--color-primary); 33 | } 34 | 35 | &:checked::before { 36 | clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); 37 | box-shadow: inset 1em 1em var(--color-text-on-primary); 38 | } 39 | 40 | &:not(:disabled) { 41 | &:focus-visible { 42 | outline: 1px solid var(--color-border); 43 | outline-offset: 3px; 44 | } 45 | } 46 | 47 | &:disabled { 48 | --container-color: var(--container-disabled); 49 | color: var(--container-disabled); 50 | cursor: default; 51 | background-color: var(--color-input-disabled); 52 | border-color: var(--color-border-soft); 53 | 54 | &:checked { 55 | &::before { 56 | clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); 57 | box-shadow: inset 1em 1em var(--color-border-soft); 58 | } 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Checkbox/types/index.ts: -------------------------------------------------------------------------------- 1 | import { InputHTMLAttributes, ReactElement } from "react"; 2 | 3 | export type CheckboxProps = InputHTMLAttributes & { 4 | label?: string | ReactElement; 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Errors/index.tsx: -------------------------------------------------------------------------------- 1 | import { sizes } from "../../settings/theme"; 2 | import classes from "../../utils/classes"; 3 | import style from "./style/Errors.module.scss"; 4 | import { PiInfo } from "react-icons/pi"; 5 | 6 | export default function Errors({ error, centered = false }: { error?: unknown; centered?: boolean }) { 7 | return error ? ( 8 |
9 |

10 | 11 | {error.toString()} 12 |

13 |
14 | ) : null; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Errors/style/Errors.module.scss: -------------------------------------------------------------------------------- 1 | .errors { 2 | color: var(--color-text-error); 3 | margin: var(--m-xxs) 0; 4 | 5 | p { 6 | margin: 0; 7 | display: flex; 8 | align-items: center; 9 | gap: var(--m-xxs); 10 | text-align: left; 11 | } 12 | } 13 | 14 | .centered { 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | flex-direction: column; 19 | height: 100%; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Input/style/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin optional-at-root($sel) { 2 | @at-root #{if(not &, $sel, selector-append(&, $sel))} { 3 | @content; 4 | } 5 | } 6 | 7 | @mixin placeholder { 8 | @include optional-at-root("::-webkit-input-placeholder") { 9 | @content; 10 | } 11 | 12 | @include optional-at-root(":-moz-placeholder") { 13 | @content; 14 | } 15 | 16 | @include optional-at-root("::-moz-placeholder") { 17 | @content; 18 | } 19 | 20 | @include optional-at-root(":-ms-input-placeholder") { 21 | @content; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Input/types/index.ts: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes, InputHTMLAttributes, ReactElement } from "react"; 2 | 3 | export type inputTypes = 4 | | "date" 5 | | "datetime-local" 6 | | "email" 7 | | "file" 8 | | "month" 9 | | "number" 10 | | "password" 11 | | "search" 12 | | "tel" 13 | | "text" 14 | | "time" 15 | | "url" 16 | | "week"; 17 | 18 | export type InputVariants = "fluid" | "small"; 19 | export type InputOption = ButtonHTMLAttributes & { required?: boolean }; 20 | 21 | export type InputProps = InputHTMLAttributes & 22 | InputHTMLAttributes & 23 | InputHTMLAttributes & { 24 | as?: "input" | "textarea"; 25 | label?: string | ReactElement; 26 | icon?: ReactElement; 27 | iconAfter?: ReactElement; 28 | error?: string; 29 | options?: { [key: string]: InputOption }; 30 | variants?: InputVariants[]; 31 | type?: inputTypes; 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Portal/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactPortal, useEffect, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import { PortalProps } from "./types"; 4 | 5 | export default function Portal({ children, className = "root-portal", el = "div" }: PortalProps): ReactPortal { 6 | const [container] = useState(() => { 7 | // This will be executed only on the initial render 8 | // https://reactjs.org/docs/hooks-reference.html#lazy-initial-state 9 | return document.createElement(el); 10 | }); 11 | 12 | useEffect(() => { 13 | container.classList.add(className); 14 | container.setAttribute("role", "complementary"); 15 | container.setAttribute("aria-label", "Notifications"); 16 | document.body.appendChild(container); 17 | return () => { 18 | document.body.removeChild(container); 19 | }; 20 | }, []); 21 | 22 | return createPortal(children, container); 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Portal/types/index.ts: -------------------------------------------------------------------------------- 1 | export type PortalProps = React.PropsWithChildren<{ 2 | className?: string; 3 | el?: string; 4 | }>; 5 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Stepper/hooks/useStepper.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import { Step, StepperProps } from "../types"; 3 | 4 | export default function useStepper(steps: Step[], initialStep = 0, skipHeader: boolean): StepperProps { 5 | const [current, setCurrent] = useState(initialStep); 6 | 7 | const step = useMemo(() => steps[current], [current, steps]); 8 | 9 | return { steps, current, step, setCurrent, skipHeader }; 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Stepper/index.tsx: -------------------------------------------------------------------------------- 1 | import { colors } from "../../settings/theme"; 2 | import classes from "../../utils/classes"; 3 | import { StepperProps } from "./types"; 4 | import style from "./style/Stepper.module.scss"; 5 | import { PiCheckBold } from "react-icons/pi"; 6 | 7 | export default function Stepper({ steps, current, clickable, setCurrent, skipHeader }: StepperProps) { 8 | return ( 9 |
10 | {steps.map((step, i) => { 11 | if (step.disabled) return null; 12 | const done = i < current; 13 | 14 | const Element = clickable ? "button" : "div"; 15 | 16 | const buttonProps: any = clickable 17 | ? { 18 | onClick: () => setCurrent(i), 19 | type: "button", 20 | } 21 | : {}; 22 | 23 | let displayNumber = i + 1; 24 | if (skipHeader && displayNumber > 1) { 25 | displayNumber--; 26 | } 27 | 28 | return ( 29 | 33 |
{done ? : displayNumber}
34 |
{step.label}
35 |
36 | ); 37 | })} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Stepper/style/Stepper.module.scss: -------------------------------------------------------------------------------- 1 | $transition: all 0.3s ease-out; 2 | 3 | .stepper { 4 | display: flex; 5 | gap: var(--m); 6 | margin: var(--m-xs) auto; 7 | justify-content: center; 8 | 9 | .step { 10 | display: flex; 11 | gap: var(--m-xxs); 12 | align-items: center; 13 | transition: $transition; 14 | 15 | .badge { 16 | border-radius: 50%; 17 | border: 1px solid var(--color-border); 18 | aspect-ratio: 1; 19 | width: 2em; 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | transition: $transition; 24 | } 25 | 26 | &.active { 27 | color: var(--color-primary); 28 | 29 | .badge { 30 | background-color: var(--color-primary); 31 | color: var(--color-text-on-primary); 32 | border: none; 33 | } 34 | } 35 | &.done { 36 | .badge { 37 | border-color: var(--color-primary); 38 | } 39 | } 40 | 41 | &:not(:first-of-type) { 42 | &:before { 43 | content: ""; 44 | height: 1px; 45 | width: calc(min(140px, 4vw)); 46 | background-color: var(--color-border); 47 | border-radius: 2px; 48 | margin-right: var(--m-xs); 49 | } 50 | } 51 | } 52 | 53 | .stepWide { 54 | &:not(:first-of-type) { 55 | &:before { 56 | width: calc(min(120px, 10vw)); 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Stepper/types/index.ts: -------------------------------------------------------------------------------- 1 | export type Step = { 2 | label: string; 3 | id?: string | number; 4 | disabled?: boolean; 5 | }; 6 | 7 | export type StepperProps = { 8 | steps: Step[]; 9 | current: number; 10 | setCurrent: (step: number) => void; 11 | step: Step; 12 | clickable?: boolean; 13 | skipHeader: boolean; 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Table/storyData.ts: -------------------------------------------------------------------------------- 1 | const storyData = [ 2 | { 3 | id: 1, 4 | Name: { 5 | raw: "John Doe", 6 | content: "John Doe", 7 | captionInfo: "This is a caption example", 8 | }, 9 | Age: 25, 10 | Email: "john.doe@example.com", 11 | }, 12 | { 13 | id: 2, 14 | Name: { 15 | raw: "Huge line", 16 | content: 17 | "Huge line with overflow. Lorem ipsum dolor sit amet. Aequam memento rebus in arduis servare mentem. Ubi fini saeculi fortunae comutatione supersunt in elipse est.", 18 | tooltip: 19 | "Huge line with overflow. Lorem ipsum dolor sit amet. Aequam memento rebus in arduis servare mentem. Ubi fini saeculi fortunae comutatione supersunt in elipse est.", 20 | }, 21 | Age: 30, 22 | Email: "jane.smith@example.com", 23 | }, 24 | { 25 | id: 3, 26 | Name: "Mike Johnson", 27 | Age: 28, 28 | Email: "mike.johnson@example.com", 29 | }, 30 | { 31 | id: 4, 32 | Name: "Emily Davis", 33 | Age: 32, 34 | Email: "emily.davis@example.com", 35 | }, 36 | { 37 | id: 5, 38 | Name: "Alex Wilson", 39 | Age: 27, 40 | Email: "alex.wilson@example.com", 41 | }, 42 | { 43 | id: 6, 44 | Name: "Sarah Thompson", 45 | Age: 29, 46 | Email: "sarah.thompson@example.com", 47 | }, 48 | { 49 | id: 7, 50 | Name: "Daniel Anderson", 51 | Age: 31, 52 | Email: "daniel.anderson@example.com", 53 | }, 54 | { 55 | id: 8, 56 | Name: "Michelle Brown", 57 | Age: 26, 58 | Email: "michelle.brown@example.com", 59 | }, 60 | { 61 | id: 9, 62 | Name: "Robert Taylor", 63 | Age: 33, 64 | Email: "robert.taylor@example.com", 65 | }, 66 | { 67 | id: 10, 68 | Name: "Laura Miller", 69 | Age: 28, 70 | Email: "laura.miller@example.com", 71 | }, 72 | { 73 | id: 11, 74 | Name: "Michael Johnson", 75 | Age: 35, 76 | email: "michael.johnson@example.com", 77 | }, 78 | { 79 | id: 12, 80 | Name: "Jessica Davis", 81 | Age: 27, 82 | email: "jessica.davis@example.com", 83 | }, 84 | { 85 | id: 13, 86 | Name: "Andrew Smith", 87 | Age: 32, 88 | email: "andrew.smith@example.com", 89 | }, 90 | { 91 | id: 14, 92 | Name: "Emily Wilson", 93 | Age: 29, 94 | email: "emily.wilson@example.com", 95 | }, 96 | { 97 | id: 15, 98 | Name: "David Anderson", 99 | Age: 33, 100 | email: "david.anderson@example.com", 101 | }, 102 | { 103 | id: 16, 104 | Name: "Sophia Brown", 105 | Age: 28, 106 | email: "sophia.brown@example.com", 107 | }, 108 | { 109 | id: 17, 110 | Name: "Matthew Taylor", 111 | Age: 31, 112 | email: "matthew.taylor@example.com", 113 | }, 114 | { 115 | id: 18, 116 | Name: "Olivia Johnson", 117 | Age: 26, 118 | email: "olivia.johnson@example.com", 119 | }, 120 | { 121 | id: 19, 122 | Name: "James Davis", 123 | Age: 30, 124 | email: "james.davis@example.com", 125 | }, 126 | { 127 | id: 20, 128 | Name: "Grace Smith", 129 | Age: 27, 130 | email: "grace.smith@example.com", 131 | }, 132 | ]; 133 | 134 | export default storyData; 135 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Table/types/index.ts: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, ReactElement } from "react"; 2 | 3 | type Style = { readonly [key: string]: string }; 4 | 5 | type Primitive = string | number | boolean | null | undefined; 6 | 7 | export type TableComposite = { 8 | raw: Primitive; 9 | content: Primitive | React.ReactElement; 10 | tooltip?: string; 11 | captionInfo?: string; 12 | }; 13 | 14 | export type TableValue = Primitive | TableComposite; 15 | 16 | export type TableDatum = { 17 | [key: string]: TableValue; 18 | }; 19 | 20 | export type TableData = TableDatum[]; 21 | 22 | export type TableProps = { 23 | data: TableData; 24 | keyAsId?: string; 25 | theme?: Style; 26 | mergeThemes?: boolean; 27 | highlightColumns?: string[]; 28 | hideColumns?: string[]; 29 | emptyState?: ReactElement; 30 | heading?: ReactElement; 31 | background?: "zebra" | "dark" | "light" | "transparent"; 32 | columnWidths?: string[]; 33 | columnAlignments?: ("left" | "center" | "right" | "")[]; 34 | fixHeader?: boolean; 35 | onRowClick?: (row: TableDatum) => void; 36 | }; 37 | 38 | export type RowProps = { 39 | datum: TableDatum; 40 | isHeading?: boolean; 41 | onClick?: (row: TableDatum) => void; 42 | }; 43 | 44 | export type CellProps = PropsWithChildren<{ 45 | cellClass?: string; 46 | cellStyle: Style; 47 | tooltip?: string; 48 | }>; 49 | -------------------------------------------------------------------------------- /frontend/src/importer/components/ToggleFilter/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import classes from "../../utils/classes"; 3 | import { Option, ToggleFilterProps } from "./types"; 4 | import style from "./style/ToggleFilter.module.scss"; 5 | 6 | function ToggleFilter({ options, onChange, className }: ToggleFilterProps) { 7 | const [selectedOption, setSelectedOption] = useState(null); 8 | const toggleFilterClassName = classes([style.toggleFilter, className]); 9 | 10 | useEffect(() => { 11 | const defaultSelected = options.find((option) => option.selected); 12 | setSelectedOption(defaultSelected ? defaultSelected.label : options[0]?.label); 13 | }, [options]); 14 | 15 | const handleClick = (option: Option) => { 16 | setSelectedOption(option.label); 17 | if (onChange) { 18 | onChange(option.filterValue); 19 | } 20 | }; 21 | 22 | const getOptionColor = (option: Option) => { 23 | if (option.color) { 24 | return option.color; 25 | } 26 | return selectedOption === option.label ? "var(--color-tertiary)" : "var(--color-text)"; 27 | }; 28 | 29 | return ( 30 |
31 | {options.map((option) => ( 32 | 43 | ))} 44 |
45 | ); 46 | } 47 | 48 | export default ToggleFilter; 49 | -------------------------------------------------------------------------------- /frontend/src/importer/components/ToggleFilter/style/ToggleFilter.module.scss: -------------------------------------------------------------------------------- 1 | .toggleFilter { 2 | display: flex; 3 | align-items: center; 4 | background-color: var(--color-input-background); 5 | border-radius: 20px; 6 | overflow: hidden; 7 | min-height: 36px; 8 | } 9 | 10 | .toggleOption { 11 | padding: 8px 16px; 12 | cursor: pointer; 13 | 14 | &.selected { 15 | background-color: var(--color-text-on-tertiary); 16 | border-radius: 20px; 17 | transition: background-color 0.2s, color 0.2s; 18 | } 19 | 20 | .defaultColor { 21 | color: var(--color-text); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/importer/components/ToggleFilter/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface Option { 2 | label: string; 3 | filterValue: string, 4 | selected: boolean; 5 | color?: string; 6 | } 7 | 8 | export interface ToggleFilterProps { 9 | options: Option[]; 10 | className?: string; 11 | onChange: (option: string) => void; 12 | } -------------------------------------------------------------------------------- /frontend/src/importer/components/Tooltip/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import classes from "../../utils/classes"; 4 | import getStringLengthOfChildren from "../../utils/getStringLengthOfChildren"; 5 | import { AsMap, TooltipProps } from "./types"; 6 | import style from "./style/Tooltip.module.scss"; 7 | import { PiInfo } from "react-icons/pi"; 8 | 9 | export default function Tooltip({ as, className, title, children, icon = , ...props }: TooltipProps) { 10 | const Tag: any = as || "span"; 11 | 12 | const length = getStringLengthOfChildren(title); 13 | const wrapperClasses = classes([style.tooltip, className, length > 30 && style.multiline]); 14 | 15 | const [tooltipVisible, setTooltipVisible] = useState(false); 16 | const [position, setPosition] = useState({ top: 0, left: 0 }); 17 | const targetRef = useRef(null); 18 | 19 | // Create a ref to attach the tooltip portal to 20 | const tooltipContainer = useRef(document.createElement("div")); 21 | 22 | useEffect(() => { 23 | // Appending the tooltip container to the body on mount 24 | document.body.appendChild(tooltipContainer.current); 25 | 26 | // Removing the tooltip container from the body on unmount 27 | return () => { 28 | document.body.removeChild(tooltipContainer.current); 29 | }; 30 | }, []); 31 | 32 | const showTooltip = () => { 33 | if (targetRef.current) { 34 | const rect = targetRef.current.getBoundingClientRect(); 35 | setPosition({ 36 | top: rect.bottom + window.scrollY, 37 | left: rect.left + rect.width / 2 + window.scrollX, 38 | }); 39 | setTooltipVisible(true); 40 | } 41 | }; 42 | 43 | const hideTooltip = () => { 44 | setTooltipVisible(false); 45 | }; 46 | 47 | const tooltipMessage = tooltipVisible && ( 48 | 49 | {title} 50 | 51 | ); 52 | 53 | return ( 54 | 55 | {children} 56 | 57 | {icon} 58 | {tooltipMessage} 59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Tooltip/style/Tooltip.module.scss: -------------------------------------------------------------------------------- 1 | $side: var(--m-xxxs); 2 | $height: calc($side * 1.732); 3 | 4 | .tooltip { 5 | display: inline-flex; 6 | align-items: center; 7 | gap: var(--m-xs); 8 | 9 | .icon { 10 | position: relative; 11 | display: block; 12 | cursor: pointer; 13 | } 14 | 15 | &.multiline .message { 16 | width: 260px; 17 | white-space: normal; 18 | } 19 | } 20 | 21 | .message { 22 | position: absolute; 23 | transform: translateX(-50%); 24 | background-color: var(--color-background-modal); 25 | z-index: 3; 26 | padding: var(--m-xxs) var(--m-xs); 27 | border-radius: var(--border-radius); 28 | margin-top: var(--m-xs); 29 | box-shadow: 0 0 0 1px var(--color-border), 0 5px 15px rgba(0, 0, 0, 0.2); 30 | max-width: 300px; 31 | 32 | &::after, 33 | &::before { 34 | position: absolute; 35 | top: calc($height * -1); 36 | left: 50%; 37 | border-left: $side solid transparent; 38 | border-right: $side solid transparent; 39 | border-bottom: $height solid var(--color-border); 40 | content: ""; 41 | font-size: 0; 42 | line-height: 0; 43 | width: 0; 44 | transform: translateX(-50%); 45 | } 46 | 47 | &::after { 48 | top: calc($height * -1 + 2px); 49 | border-bottom: $height solid var(--color-background-modal); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/importer/components/Tooltip/types/index.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export type AsMap = { 4 | div: React.HTMLProps; 5 | span: React.HTMLProps; 6 | p: React.HTMLProps; 7 | }; 8 | 9 | export type TooltipProps = { 10 | as?: T; 11 | title?: string | ReactNode; 12 | icon?: ReactNode; 13 | } & AsMap[T]; 14 | -------------------------------------------------------------------------------- /frontend/src/importer/components/UploaderWrapper/UploaderWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useDropzone } from "react-dropzone"; 3 | import { useTranslation } from "react-i18next"; 4 | import useThemeStore from "../../stores/theme"; 5 | import { UploaderWrapperProps } from "./types"; 6 | import { FileIcon, UploadIcon, Loader2 } from "lucide-react"; 7 | import { Button } from "../../components/ui/button"; 8 | import { cn } from "../../../utils/classes"; 9 | 10 | export default function UploaderWrapper({ onSuccess, setDataError, ...props }: UploaderWrapperProps) { 11 | const [loading, setLoading] = useState(false); 12 | const theme = useThemeStore((state) => state.theme); 13 | const { t } = useTranslation(); 14 | 15 | const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ 16 | noClick: true, 17 | noKeyboard: true, 18 | maxFiles: 1, 19 | accept: { 20 | "application/vnd.ms-excel": [".xls"], 21 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"], 22 | "text/csv": [".csv"], 23 | }, 24 | onDropRejected: (fileRejections) => { 25 | setLoading(false); 26 | const errorMessage = fileRejections[0].errors[0].message; 27 | setDataError(errorMessage); 28 | }, 29 | onDropAccepted: async ([file]) => { 30 | setLoading(true); 31 | onSuccess(file); 32 | setLoading(false); 33 | }, 34 | }); 35 | 36 | return ( 37 |
38 |
45 | 46 | {isDragActive ? ( 47 |
48 | 49 |

{t("Drop your file here")}

50 |
51 | ) : loading ? ( 52 |
53 | 54 |

{t("Loading...")}

55 |
56 | ) : ( 57 |
58 | 59 |
60 |

{t("Drop your file here")}

61 |

{t("Supports CSV, XLS, and XLSX files")}

62 |
63 |
64 | {t("or")} 65 | 73 |
74 |
75 | )} 76 |
77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /frontend/src/importer/components/UploaderWrapper/types/index.ts: -------------------------------------------------------------------------------- 1 | import { UploaderProps } from "../../../features/uploader/types"; 2 | 3 | export type UploaderWrapperProps = Omit & {}; 4 | -------------------------------------------------------------------------------- /frontend/src/importer/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "../../../utils/classes" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | isLoading?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, isLoading, children, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 53 | {isLoading ? ( 54 | <> 55 | 56 | 57 | 58 | 59 | Loading... 60 | 61 | ) : ( 62 | children 63 | )} 64 | 65 | ) 66 | } 67 | ) 68 | Button.displayName = "Button" 69 | 70 | export { Button, buttonVariants } -------------------------------------------------------------------------------- /frontend/src/importer/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { Check } from "lucide-react" 4 | 5 | import { cn } from "../../../utils/classes" 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )) 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | 28 | export { Checkbox } -------------------------------------------------------------------------------- /frontend/src/importer/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "../../../utils/classes" 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider 7 | 8 | const Tooltip = TooltipPrimitive.Root 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 25 | )) 26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 27 | 28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } -------------------------------------------------------------------------------- /frontend/src/importer/features/complete/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Button } from "@chakra-ui/button"; 3 | import Box from "../../components/Box"; 4 | import { CompleteProps } from "./types"; 5 | import style from "./style/Complete.module.scss"; 6 | import { PiArrowCounterClockwise, PiCheckBold } from "react-icons/pi"; 7 | 8 | export default function Complete({ reload, close, isModal }: CompleteProps) { 9 | const { t } = useTranslation(); 10 | return ( 11 | 12 | <> 13 | 14 | 15 | 16 |
{t("Import Successful")}
17 |
18 | 21 | {isModal && ( 22 | 25 | )} 26 |
27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/importer/features/complete/style/Complete.module.scss: -------------------------------------------------------------------------------- 1 | .content.content { 2 | max-width: 1000px; 3 | padding-top: var(--m); 4 | height: 100%; 5 | flex: 1 0 100px; 6 | box-shadow: none; 7 | background-color: transparent; 8 | align-self: center; 9 | 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | font-size: var(--font-size-xl); 14 | flex-direction: column; 15 | gap: var(--m); 16 | text-align: center; 17 | position: relative; 18 | 19 | .icon { 20 | width: 64px; 21 | height: 64px; 22 | isolation: isolate; 23 | position: relative; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | 28 | &::before { 29 | content: ""; 30 | position: absolute; 31 | inset: 0; 32 | border-radius: 50%; 33 | background-color: var(--color-green-ui); 34 | z-index: -1; 35 | } 36 | 37 | svg { 38 | width: 38%; 39 | height: 38%; 40 | object-fit: contain; 41 | color: var(--color-text-on-primary); 42 | } 43 | } 44 | 45 | .actions { 46 | display: flex; 47 | gap: var(--m-l); 48 | align-items: center; 49 | justify-content: center; 50 | margin-top: var(--m-xxl); 51 | 52 | & > * { 53 | flex: 1 0 190px; 54 | } 55 | 56 | button { 57 | width: 50%; 58 | } 59 | } 60 | } 61 | 62 | .spinner { 63 | border: 1px solid var(--color-border); 64 | margin-top: var(--m); 65 | padding: var(--m); 66 | border-radius: var(--border-radius-1); 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/importer/features/complete/types/index.ts: -------------------------------------------------------------------------------- 1 | export type CompleteProps = { 2 | reload: () => void; 3 | close: () => void; 4 | isModal: boolean; 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/importer/features/main/hooks/useMutableLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export default function useMutableLocalStorage(key: string, initialValue: any) { 4 | // State to store our value 5 | // Pass initial state function to useState so logic is only executed once 6 | const getLocalStorage = () => { 7 | if (typeof window === "undefined") { 8 | return initialValue; 9 | } 10 | try { 11 | // Get from local storage by key 12 | const item = window.localStorage.getItem(key); 13 | // Parse stored json or if none return initialValue 14 | return item ? JSON.parse(item) : initialValue; 15 | } catch (error) { 16 | // If error also return initialValue 17 | 18 | return initialValue; 19 | } 20 | }; 21 | const [storedValue, setStoredValue] = useState(getLocalStorage()); 22 | 23 | useEffect(() => { 24 | setStoredValue(getLocalStorage()); 25 | }, [key]); 26 | 27 | // Return a wrapped version of useState's setter function that ... 28 | // ... persists the new value to localStorage. 29 | const setValue = (value: any) => { 30 | try { 31 | // Allow value to be a function so we have same API as useState 32 | const valueToStore = value instanceof Function ? value(storedValue) : value; 33 | // Save state 34 | setStoredValue(valueToStore); 35 | // Save to local storage 36 | if (typeof window !== "undefined") { 37 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 38 | } 39 | } catch (error) { 40 | 41 | } 42 | }; 43 | 44 | return [storedValue, setValue]; 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/importer/features/main/hooks/useStepNavigation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import useStepper from "../../../components/Stepper/hooks/useStepper"; 4 | import { Steps } from "../types"; 5 | import useMutableLocalStorage from "./useMutableLocalStorage"; 6 | 7 | export const StepEnum = { 8 | Upload: 0, 9 | RowSelection: 1, 10 | MapColumns: 2, 11 | Validation: 3, 12 | Complete: 4, 13 | }; 14 | 15 | const calculateNextStep = (nextStep: number, skipHeader: boolean) => { 16 | if (skipHeader) { 17 | switch (nextStep) { 18 | case StepEnum.Upload: 19 | case StepEnum.RowSelection: 20 | return StepEnum.MapColumns; 21 | case StepEnum.MapColumns: 22 | return StepEnum.Validation; 23 | case StepEnum.Validation: 24 | return StepEnum.Complete; 25 | default: 26 | return nextStep; 27 | } 28 | } 29 | return nextStep; 30 | }; 31 | 32 | const getStepConfig = (skipHeader: boolean) => { 33 | return [ 34 | { label: "Upload", id: Steps.Upload }, 35 | { label: "Select Header", id: Steps.RowSelection, disabled: skipHeader }, 36 | { label: "Map Columns", id: Steps.MapColumns }, 37 | { label: "Validation", id: Steps.Validation }, 38 | ]; 39 | }; 40 | 41 | function useStepNavigation(initialStep: number, skipHeader: boolean) { 42 | const [t] = useTranslation(); 43 | const translatedSteps = getStepConfig(skipHeader).map((step) => ({ 44 | ...step, 45 | label: t(step.label), 46 | })); 47 | const stepper = useStepper(translatedSteps, StepEnum.Upload, skipHeader); 48 | const [storageStep, setStorageStep] = useMutableLocalStorage(`tf_steps`, ""); 49 | const [currentStep, setCurrentStep] = useState(initialStep); 50 | 51 | const goBack = (backStep = 0) => { 52 | backStep = backStep || currentStep - 1 || 0; 53 | setStep(backStep); 54 | }; 55 | 56 | const goNext = (nextStep = 0) => { 57 | nextStep = nextStep || currentStep + 1 || 0; 58 | const calculatedStep = calculateNextStep(nextStep, skipHeader); 59 | setStep(calculatedStep); 60 | }; 61 | 62 | const setStep = (newStep: number) => { 63 | setCurrentStep(newStep); 64 | setStorageStep(newStep); 65 | stepper.setCurrent(newStep); 66 | }; 67 | 68 | useEffect(() => { 69 | stepper.setCurrent(storageStep || 0); 70 | setCurrentStep(storageStep || 0); 71 | }, [storageStep]); 72 | 73 | return { 74 | currentStep: storageStep || currentStep, 75 | setStep, 76 | goBack, 77 | goNext, 78 | stepper, 79 | stepId: stepper?.step?.id, 80 | setStorageStep, 81 | }; 82 | } 83 | 84 | export default useStepNavigation; 85 | -------------------------------------------------------------------------------- /frontend/src/importer/features/main/style/Main.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | max-height: 100%; 5 | padding: 16px 8px 8px 8px; 6 | } 7 | 8 | .content { 9 | padding: 20px; 10 | flex: 1; 11 | overflow: hidden; 12 | } 13 | 14 | .status { 15 | display: flex; 16 | align-items: center; 17 | justify-content: space-between; 18 | gap: var(--m); 19 | padding: 0 var(--m-s) var(--m-s) var(--m-s); 20 | } 21 | 22 | .spinner { 23 | border: 1px solid var(--color-border); 24 | margin-top: var(--m); 25 | padding: var(--m); 26 | border-radius: var(--border-radius-1); 27 | position: absolute; 28 | top: 50%; 29 | left: 50%; 30 | transform: translate(-50%, -50%); 31 | } 32 | 33 | $closeSide: calc(var(--m-xl) * 36 / 48); 34 | 35 | .close.close { 36 | position: absolute; 37 | right: var(--m-xs, 0.5rem); 38 | top: var(--m-xs, 0.5rem); 39 | border-radius: 50%; 40 | min-width: $closeSide; 41 | height: $closeSide; 42 | aspect-ratio: 1; 43 | font-size: var(--font-size-xl); 44 | padding: 0; 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/importer/features/main/types/index.ts: -------------------------------------------------------------------------------- 1 | export enum Steps { 2 | Upload = "upload", 3 | RowSelection = "row-selection", 4 | MapColumns = "map-columns", 5 | Validation = "validation", 6 | } 7 | 8 | export type FileRow = { 9 | index: number; 10 | values: string[]; 11 | }; 12 | 13 | export type FileData = { 14 | fileName: string; 15 | rows: FileRow[]; 16 | sheetList: string[]; 17 | errors: string[]; 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/importer/features/map-columns/hooks/useNameChange.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useTransformValue = (initialValue: string) => { 4 | const [transformedValue, setTransformedValue] = useState(""); 5 | 6 | useEffect(() => { 7 | const keyValue = initialValue.replace(/\s/g, "_").toLowerCase(); 8 | setTransformedValue(keyValue); 9 | }, [initialValue]); 10 | 11 | const transformValue = (value: string) => { 12 | const keyValue = value.replace(/\s/g, "_").toLowerCase(); 13 | setTransformedValue(keyValue); 14 | }; 15 | 16 | return { transformedValue, transformValue }; 17 | }; 18 | 19 | export default useTransformValue; 20 | -------------------------------------------------------------------------------- /frontend/src/importer/features/map-columns/style/MapColumns.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | height: 100%; 3 | 4 | form { 5 | display: flex; 6 | flex-direction: column; 7 | height: 100%; 8 | gap: 1rem; 9 | 10 | .tableWrapper { 11 | display: flex; 12 | max-height: 400px; 13 | overflow-y: auto; 14 | padding: 1px; 15 | border-radius: 0.5rem; 16 | border: 1px solid var(--color-border); 17 | background-color: var(--color-background); 18 | } 19 | 20 | .actions { 21 | display: flex; 22 | justify-content: space-between; 23 | align-items: center; 24 | padding-top: 0.5rem; 25 | } 26 | } 27 | } 28 | 29 | .samples { 30 | overflow: hidden; 31 | text-overflow: ellipsis; 32 | line-height: 1.2; 33 | white-space: nowrap; 34 | 35 | & > small { 36 | background-color: var(--color-input-background); 37 | font-family: monospace; 38 | padding: 0.25rem 0.5rem; 39 | border-radius: 0.25rem; 40 | font-size: 0.75rem; 41 | display: inline-block; 42 | color: var(--color-text); 43 | 44 | & + small { 45 | margin-left: 0.25rem; 46 | } 47 | } 48 | } 49 | 50 | .spinner { 51 | border: 1px solid var(--color-border); 52 | margin-top: var(--m); 53 | padding: var(--m); 54 | border-radius: var(--border-radius-1); 55 | } 56 | 57 | .errorContainer { 58 | display: flex; 59 | justify-content: center; 60 | max-width: 60vw; 61 | position: absolute; 62 | top: -30px; 63 | left: 50%; 64 | transform: translateX(-50%); 65 | } 66 | 67 | .schemalessTextInput { 68 | width: 210px; 69 | } 70 | -------------------------------------------------------------------------------- /frontend/src/importer/features/map-columns/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Template } from "../../../types"; 2 | import { FileData } from "../../main/types"; 3 | 4 | export type TemplateColumnMapping = { 5 | key: string; 6 | include: boolean; 7 | selected?: boolean; 8 | }; 9 | 10 | export type MapColumnsProps = { 11 | template: Template; 12 | data: FileData; 13 | columnMapping: { [index: number]: TemplateColumnMapping }; 14 | selectedHeaderRow: number | null; 15 | skipHeaderRowSelection?: boolean; 16 | onSuccess: (columnMapping: { [index: number]: TemplateColumnMapping }) => void; 17 | onCancel: () => void; 18 | isSubmitting: boolean; 19 | importerKey?: string; // Key of the importer for API calls 20 | backendUrl?: string; // Backend URL for API calls 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/importer/features/row-selection/style/RowSelection.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | flex-grow: 1; 3 | height: 100%; 4 | 5 | form { 6 | display: flex; 7 | flex-direction: column; 8 | height: 100%; 9 | gap: var(--m); 10 | 11 | .tableWrapper { 12 | display: flex; 13 | overflow-y: auto; 14 | padding: 1px; 15 | margin-right: -20px; 16 | padding-right: 21px; 17 | max-height: 400px; 18 | } 19 | 20 | .actions { 21 | display: flex; 22 | justify-content: space-between; 23 | } 24 | } 25 | } 26 | 27 | .samples { 28 | overflow: hidden; 29 | text-overflow: ellipsis; 30 | line-height: 1; 31 | white-space: nowrap; 32 | 33 | & > small { 34 | background-color: var(--color-input-background); 35 | font-family: monospace; 36 | padding: var(--m-xxxxs); 37 | border-radius: var(--border-radius-1); 38 | font-size: var(--font-size-xs); 39 | display: inline-block; 40 | 41 | & + small { 42 | margin-left: var(--m-xxxxs); 43 | } 44 | } 45 | } 46 | .spinner { 47 | border: 1px solid var(--color-border); 48 | margin-top: var(--m); 49 | padding: var(--m); 50 | border-radius: var(--border-radius-1); 51 | } 52 | 53 | .inputRadio { 54 | margin-right: 10px; 55 | } 56 | 57 | .headingCaption { 58 | padding: 12px 0 10px 0; 59 | color: var(--color-text-secondary); 60 | font-weight: 400; 61 | height: 48px; 62 | vertical-align: middle; 63 | text-align: center; 64 | 65 | // TODO: Hacky solution to update the tooltip title text, update this 66 | span > span:nth-child(1) > span { 67 | font-weight: 400; 68 | } 69 | } 70 | 71 | .warningIcon { 72 | margin-right: 7px; 73 | } 74 | -------------------------------------------------------------------------------- /frontend/src/importer/features/row-selection/types/index.ts: -------------------------------------------------------------------------------- 1 | import { FileData } from "../../main/types"; 2 | 3 | export type RowSelectionProps = { 4 | data: FileData; 5 | onSuccess: () => void; 6 | onCancel: () => void; 7 | selectedHeaderRow: number | null; 8 | setSelectedHeaderRow: (id: number) => void; 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/importer/features/uploader/hooks/useTemplateTable.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import Tooltip from "../../../components/Tooltip"; 4 | import { TemplateColumn } from "../../../types"; 5 | import { PiCheckBold } from "react-icons/pi"; 6 | 7 | export default function useTemplateTable(fields: TemplateColumn[] = []) { 8 | if (!fields) { 9 | return []; 10 | } 11 | const { t } = useTranslation(); 12 | const expectedColumnKey = t("Expected Column"); 13 | const requiredKey = t("Required"); 14 | const result = useMemo(() => { 15 | return fields.map((item) => ({ 16 | [expectedColumnKey]: item?.description 17 | ? { 18 | raw: item.name, 19 | content: ( 20 |
21 | {item.name} 22 |
23 | ), 24 | } 25 | : item.name, 26 | [requiredKey]: { raw: item?.required ? 1 : 0, content: item?.required ? : <> }, 27 | })); 28 | }, [fields]); 29 | 30 | return result; 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/importer/features/uploader/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Button } from "@chakra-ui/button"; 3 | import Table from "../../components/Table"; 4 | import UploaderWrapper from "../../components/UploaderWrapper/UploaderWrapper"; 5 | import useThemeStore from "../../stores/theme"; 6 | import useTemplateTable from "./hooks/useTemplateTable"; 7 | import { UploaderProps } from "./types"; 8 | import style from "./style/Uploader.module.scss"; 9 | import { PiDownloadSimple } from "react-icons/pi"; 10 | 11 | 12 | export default function Uploader({ template, skipHeaderRowSelection, onSuccess, showDownloadTemplateButton, setDataError }: UploaderProps) { 13 | const fields = useTemplateTable(template.columns); 14 | const theme = useThemeStore((state) => state.theme); 15 | const uploaderWrapper = ; 16 | showDownloadTemplateButton = showDownloadTemplateButton ?? true; 17 | const { t } = useTranslation(); 18 | 19 | function downloadTemplate() { 20 | const { columns } = template; 21 | const csvData = `${columns.map((obj) => obj.name).join(",")}`; 22 | 23 | const link = document.createElement("a"); 24 | link.href = URL.createObjectURL(new Blob([csvData], { type: "text/csv" })); 25 | link.download = "example.csv"; 26 | link.click(); 27 | } 28 | 29 | const downloadTemplateButton = showDownloadTemplateButton ? ( 30 | 46 | ) : null; 47 | 48 | return ( 49 |
50 | {uploaderWrapper} 51 |
52 |
53 | 54 | 55 | {downloadTemplateButton} 56 | 57 | 58 | ); 59 | } -------------------------------------------------------------------------------- /frontend/src/importer/features/uploader/style/Uploader.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | gap: var(--m); 4 | height: 100%; 5 | 6 | & > * { 7 | &:first-child { 8 | flex: 1 1 500px; 9 | overflow: hidden; 10 | } 11 | &:last-child { 12 | flex-basis: 38%; 13 | } 14 | } 15 | } 16 | 17 | .box { 18 | display: flex; 19 | flex-direction: column; 20 | gap: var(--m-s); 21 | } 22 | 23 | .tableContainer { 24 | overflow: hidden; 25 | overflow-y: scroll; 26 | height: 100%; 27 | border: 1px solid var(--color-border); 28 | border-radius: var(--border-radius-2); 29 | > div { 30 | outline: none; 31 | } 32 | .tbody { 33 | overflow: auto; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/importer/features/uploader/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Template } from "../../../types"; 2 | import { Dispatch, SetStateAction } from "react"; 3 | 4 | export type UploaderProps = { 5 | template: Template; 6 | skipHeaderRowSelection: boolean; 7 | onSuccess: (file: File) => void; 8 | showDownloadTemplateButton?: boolean; 9 | setDataError: Dispatch>; 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/importer/features/validation/index.tsx: -------------------------------------------------------------------------------- 1 | // Export the Validation component as the default export 2 | export { default } from './Validation'; 3 | -------------------------------------------------------------------------------- /frontend/src/importer/features/validation/style/Validation.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | width: 100%; 6 | } 7 | 8 | .tableWrapper { 9 | flex: 1; 10 | overflow: auto; 11 | margin-bottom: 1rem; 12 | border-radius: 8px; 13 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 14 | } 15 | 16 | .actions { 17 | display: flex; 18 | justify-content: space-between; 19 | align-items: center; 20 | padding: 1rem 0; 21 | } 22 | 23 | .errorContainer { 24 | flex: 1; 25 | margin: 0 1rem; 26 | } 27 | 28 | .errorCell { 29 | background-color: rgba(255, 0, 0, 0.1); 30 | border-radius: 4px; 31 | padding: 4px 8px; 32 | color: #e53e3e; 33 | } 34 | 35 | .errorRow { 36 | background-color: rgba(255, 0, 0, 0.03); 37 | } 38 | 39 | .editableCellContainer { 40 | position: relative; 41 | width: 100%; 42 | } 43 | 44 | .simpleInput { 45 | width: 100%; 46 | padding: 0.25rem; 47 | border-radius: 4px; 48 | font-size: 0.875rem; 49 | transition: all 0.2s; 50 | 51 | &:focus { 52 | border-color: #3182ce; 53 | box-shadow: 0 0 0 1px #3182ce; 54 | } 55 | } 56 | 57 | .errorInput { 58 | border-color: #e53e3e; 59 | 60 | &:focus { 61 | border-color: #e53e3e; 62 | box-shadow: 0 0 0 1px #e53e3e; 63 | } 64 | } 65 | 66 | .errorIcon { 67 | position: absolute; 68 | top: 50%; 69 | right: 8px; 70 | transform: translateY(-50%); 71 | width: 16px; 72 | height: 16px; 73 | border-radius: 50%; 74 | background-color: #e53e3e; 75 | color: white; 76 | font-size: 12px; 77 | display: flex; 78 | align-items: center; 79 | justify-content: center; 80 | cursor: help; 81 | } 82 | 83 | .validationTable { 84 | width: 100%; 85 | border-collapse: collapse; 86 | } 87 | 88 | .validationTable th { 89 | background-color: #f1f5f9; 90 | text-transform: none; 91 | font-weight: 600; 92 | color: #334155; 93 | padding: 12px 16px; 94 | text-align: left; 95 | border-bottom: 1px solid #e2e8f0; 96 | } 97 | 98 | .validationTable th:first-child { 99 | border-top-left-radius: 8px; 100 | } 101 | 102 | .validationTable th:last-child { 103 | border-top-right-radius: 8px; 104 | } 105 | 106 | .validationTable td { 107 | padding: 12px 16px; 108 | border-bottom: 1px solid #e2e8f0; 109 | color: #334155; 110 | } 111 | 112 | .validationTable tr:last-child td:first-child { 113 | border-bottom-left-radius: 8px; 114 | } 115 | 116 | .validationTable tr:last-child td:last-child { 117 | border-bottom-right-radius: 8px; 118 | } 119 | 120 | .validationTable tr:hover { 121 | background-color: #f8fafc; 122 | } 123 | -------------------------------------------------------------------------------- /frontend/src/importer/features/validation/types.ts: -------------------------------------------------------------------------------- 1 | import { FileData } from "../main/types"; 2 | import { TemplateColumnMapping } from "../map-columns/types"; 3 | import { Template } from "../../types"; 4 | 5 | export interface ValidationError { 6 | rowIndex: number; 7 | columnIndex: number; 8 | message: string; 9 | value: string | number; 10 | } 11 | 12 | export interface ValidationProps { 13 | template: Template; 14 | data: FileData; 15 | columnMapping: { [index: number]: TemplateColumnMapping }; 16 | selectedHeaderRow: number | null; 17 | onSuccess: (validData: any) => void; 18 | onCancel: () => void; 19 | isSubmitting: boolean; 20 | backendUrl?: string; 21 | filterInvalidRows?: boolean; 22 | disableOnInvalidRows?: boolean; 23 | } 24 | 25 | export interface ValidationState { 26 | errors: ValidationError[]; 27 | editedValues: { [rowIndex: number]: { [columnIndex: number]: string | number } }; 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/importer/hooks/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from "react"; 2 | 3 | export default function useClickOutside(ref: any | null, callback: (...args: any[]) => any): void { 4 | const staticCallback = useCallback(callback, []); 5 | 6 | useEffect(() => { 7 | const handleClickOutside = (event: any) => { 8 | if (ref && ref?.current && !ref.current.contains(event.target)) staticCallback(false); 9 | }; 10 | 11 | document.addEventListener("mousedown", handleClickOutside); 12 | return () => { 13 | document.removeEventListener("mousedown", handleClickOutside); 14 | }; 15 | }, [ref, staticCallback]); 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/importer/hooks/useCustomStyles.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export default function useCustomStyles(customStyles?: string) { 4 | useEffect(() => { 5 | if (customStyles) { 6 | const parsedStyles = JSON.parse(customStyles); 7 | if (parsedStyles) { 8 | Object.keys(parsedStyles).forEach((key) => { 9 | const root = document.documentElement; 10 | const value = parsedStyles?.[key as any]; 11 | root.style.setProperty("--" + key, value); 12 | }); 13 | } 14 | } 15 | }, [customStyles]); 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/importer/hooks/useDelayLoader.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useDelayedLoader = (isLoading: boolean, delay: number): boolean => { 4 | const [showLoader, setShowLoader] = useState(false); 5 | 6 | useEffect(() => { 7 | let timer: ReturnType; 8 | 9 | if (isLoading) { 10 | timer = setTimeout(() => { 11 | setShowLoader(true); 12 | }, delay); 13 | } else { 14 | setShowLoader(false); 15 | } 16 | 17 | return () => { 18 | clearTimeout(timer); 19 | }; 20 | }, [isLoading, delay]); 21 | 22 | return showLoader; 23 | }; 24 | 25 | export default useDelayedLoader; 26 | -------------------------------------------------------------------------------- /frontend/src/importer/hooks/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef } from "react"; 2 | import useIsomorphicLayoutEffect from "./useIsomorphicLayoutEffect"; 3 | 4 | function useEventListener(eventName: K, handler: (event: WindowEventMap[K]) => void): void; 5 | function useEventListener( 6 | eventName: K, 7 | handler: (event: HTMLElementEventMap[K]) => void, 8 | element: RefObject 9 | ): void; 10 | 11 | function useEventListener( 12 | eventName: KW | KH, 13 | handler: (event: WindowEventMap[KW] | HTMLElementEventMap[KH] | Event) => void, 14 | element?: RefObject 15 | ): void { 16 | // Create a ref that stores handler 17 | const savedHandler = useRef(handler); 18 | 19 | useIsomorphicLayoutEffect(() => { 20 | savedHandler.current = handler; 21 | }, [handler]); 22 | 23 | useEffect(() => { 24 | // Define the listening target 25 | const targetElement: T | Window = element?.current || window; 26 | if (!(targetElement && targetElement.addEventListener)) { 27 | return; 28 | } 29 | 30 | // Create event listener that calls handler function stored in ref 31 | const eventListener: typeof handler = (event) => savedHandler.current(event); 32 | 33 | targetElement.addEventListener(eventName, eventListener); 34 | 35 | // Remove event listener on cleanup 36 | return () => { 37 | targetElement.removeEventListener(eventName, eventListener); 38 | }; 39 | }, [eventName, element]); 40 | } 41 | 42 | export default useEventListener; 43 | -------------------------------------------------------------------------------- /frontend/src/importer/hooks/useIsomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from "react"; 2 | 3 | const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; 4 | 5 | export default useIsomorphicLayoutEffect; 6 | -------------------------------------------------------------------------------- /frontend/src/importer/hooks/useRect.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useLayoutEffect, useState } from "react"; 2 | import useEventListener from "./useEventListener"; 3 | import useIsomorphicLayoutEffect from "./useIsomorphicLayoutEffect"; 4 | 5 | type Size = { 6 | x: number; 7 | y: number; 8 | width: number; 9 | height: number; 10 | top: number; 11 | right: number; 12 | bottom: number; 13 | left: number; 14 | }; 15 | 16 | function useRect(): [(node: T | null) => void, Size, Function] { 17 | // Mutable values like 'ref.current' aren't valid dependencies 18 | // because mutating them doesn't re-render the component. 19 | // Instead, we use a state as a ref to be reactive. 20 | const [ref, setRef] = useState(null); 21 | const [size, setSize] = useState({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 }); 22 | 23 | // Prevent too many rendering using useCallback 24 | const updateRect = useCallback(() => { 25 | ref && setSize(ref.getBoundingClientRect()); 26 | }, [ref?.offsetHeight, ref?.offsetWidth]); 27 | 28 | useEventListener("resize", updateRect); 29 | 30 | useIsomorphicLayoutEffect(() => { 31 | updateRect(); 32 | }, [ref?.offsetHeight, ref?.offsetWidth]); 33 | 34 | useLayoutEffect(() => { 35 | window.addEventListener("mresize", updateRect); 36 | 37 | return () => window.removeEventListener("mresize", updateRect); 38 | }, []); 39 | 40 | return [setRef, size, updateRect]; 41 | } 42 | 43 | export default useRect; 44 | -------------------------------------------------------------------------------- /frontend/src/importer/hooks/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from "react"; 2 | 3 | export default function useWindowSize(): number[] { 4 | const [size, setSize] = useState([0, 0]); 5 | 6 | useLayoutEffect(() => { 7 | function updateSize() { 8 | setSize([window.innerWidth, window.innerHeight]); 9 | } 10 | window.addEventListener("resize", updateSize); 11 | updateSize(); 12 | return () => window.removeEventListener("resize", updateSize); 13 | }, []); 14 | 15 | return size; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/importer/providers/Theme.tsx: -------------------------------------------------------------------------------- 1 | import { IconContext } from "react-icons"; 2 | import { ChakraProvider, extendTheme } from "@chakra-ui/react"; 3 | import createCache from "@emotion/cache"; 4 | import { CacheProvider } from "@emotion/react"; 5 | import theme from "../settings/chakra"; 6 | import { sizes } from "../settings/theme"; 7 | import { ThemeProps } from "./types"; 8 | 9 | export const myCache = createCache({ 10 | key: "csv-importer", 11 | }); 12 | 13 | const chakraTheme = extendTheme(theme); 14 | 15 | export default function ThemeProvider({ children }: ThemeProps): React.ReactElement { 16 | return ( 17 | 18 | 19 | {children} 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/importer/providers/index.tsx: -------------------------------------------------------------------------------- 1 | import { ProvidersProps } from "./types"; 2 | import ThemeContextProvider from "./Theme"; 3 | 4 | export default function Providers({ children }: ProvidersProps) { 5 | return {children}; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/importer/providers/types/index.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export type ProvidersProps = React.PropsWithChildren<{}>; 4 | export type QueriesProps = React.PropsWithChildren<{}>; 5 | export type ThemeProps = React.PropsWithChildren<{}>; 6 | -------------------------------------------------------------------------------- /frontend/src/importer/services/api.ts: -------------------------------------------------------------------------------- 1 | // API service file - currently not in use 2 | // LLM column mapping functionality has been removed 3 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/components/alert.ts: -------------------------------------------------------------------------------- 1 | import { defineStyleConfig } from "@chakra-ui/styled-system"; 2 | 3 | const Alert = defineStyleConfig({ 4 | baseStyle: (props) => ({ 5 | container: { 6 | backgroundColor: props.status === "info" ? "var(--color-background-modal)" : "", 7 | border: "1px solid var(--color-border)", 8 | borderRadius: "var(--border-radius-2)", 9 | fontWeight: "400", 10 | }, 11 | title: { 12 | color: "inherit", 13 | }, 14 | description: { 15 | color: "inherit", 16 | }, 17 | icon: { 18 | color: "inherit", 19 | }, 20 | }), 21 | }); 22 | 23 | export { Alert }; 24 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/components/button.ts: -------------------------------------------------------------------------------- 1 | import { defineStyleConfig } from "@chakra-ui/styled-system"; 2 | 3 | const Button = defineStyleConfig({ 4 | // The styles all buttons have in common 5 | baseStyle: { 6 | fontWeight: "normal", 7 | borderRadius: "base", 8 | height: "auto", 9 | lineHeight: "1", 10 | fontSize: "inherit", 11 | border: "none", 12 | cursor: "pointer", 13 | }, 14 | 15 | sizes: { 16 | sm: { 17 | fontSize: "sm", 18 | px: 4, 19 | py: 3, 20 | }, 21 | md: { 22 | fontSize: "md", 23 | px: 6, 24 | py: 4, 25 | }, 26 | }, 27 | 28 | variants: { 29 | solid: (props) => { 30 | if (props.colorScheme === "secondary") { 31 | return { 32 | _hover: { 33 | backgroundColor: "var(--external-colors-secondary-300)", 34 | }, 35 | 36 | color: "var(--color-text-on-secondary)", 37 | }; 38 | } 39 | return { 40 | color: "var(--color-text-on-primary)", 41 | _hover: { 42 | backgroundColor: "var(--external-colors-primary-300)", 43 | }, 44 | }; 45 | }, 46 | }, 47 | 48 | defaultProps: { 49 | // variant: "outline", 50 | }, 51 | }); 52 | 53 | export { Button }; 54 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/components/index.ts: -------------------------------------------------------------------------------- 1 | export { Button } from "./button"; 2 | export { Alert } from "./alert"; 3 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/blur.ts: -------------------------------------------------------------------------------- 1 | const blur = { 2 | none: 0, 3 | sm: "4px", 4 | base: "8px", 5 | md: "12px", 6 | lg: "16px", 7 | xl: "24px", 8 | "2xl": "40px", 9 | "3xl": "64px", 10 | }; 11 | 12 | export default blur; 13 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/borders.ts: -------------------------------------------------------------------------------- 1 | const borders = { 2 | none: 0, 3 | "1px": "1px solid", 4 | "2px": "2px solid", 5 | "4px": "4px solid", 6 | "8px": "8px solid", 7 | }; 8 | 9 | export default borders; 10 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/breakpoints.ts: -------------------------------------------------------------------------------- 1 | const breakpoints = { 2 | base: "0em", 3 | sm: "30em", 4 | md: "48em", 5 | lg: "62em", 6 | xl: "80em", 7 | "2xl": "96em", 8 | }; 9 | 10 | export default breakpoints; 11 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/index.ts: -------------------------------------------------------------------------------- 1 | import blur from "./blur"; 2 | import borders from "./borders"; 3 | import breakpoints from "./breakpoints"; 4 | import colors from "./colors"; 5 | import radii from "./radius"; 6 | import shadows from "./shadows"; 7 | import sizes from "./sizes"; 8 | import { spacing } from "./spacing"; 9 | import transition from "./transition"; 10 | import typography from "./typography"; 11 | import zIndices from "./z-index"; 12 | 13 | export const foundations = { 14 | breakpoints, 15 | zIndices, 16 | radii, 17 | blur, 18 | colors, 19 | ...typography, 20 | sizes, 21 | shadows, 22 | space: spacing, 23 | borders, 24 | transition, 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/radius.ts: -------------------------------------------------------------------------------- 1 | const radii = { 2 | none: "0", 3 | sm: "var(--border-radius)", 4 | base: "var(--border-radius-2)", 5 | md: "var(--border-radius-3)", 6 | lg: "var(--border-radius-4)", 7 | xl: "var(--border-radius-5)", 8 | "2xl": "calc(var(--border-radius-5) * 1.5)", 9 | "3xl": "calc(var(--border-radius-5) * 2)", 10 | full: "9999px", 11 | }; 12 | 13 | export default radii; 14 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/shadows.ts: -------------------------------------------------------------------------------- 1 | const shadows = { 2 | xs: "0 0 0 1px rgba(0, 0, 0, 0.05)", 3 | sm: "0 1px 2px 0 rgba(0, 0, 0, 0.05)", 4 | base: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)", 5 | md: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", 6 | lg: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)", 7 | xl: "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", 8 | "2xl": "0 25px 50px -12px rgba(0, 0, 0, 0.25)", 9 | outline: "0 0 0 3px rgba(66, 153, 225, 0.6)", 10 | inner: "inset 0 2px 4px 0 rgba(0,0,0,0.06)", 11 | none: "none", 12 | "dark-lg": "rgba(0, 0, 0, 0.1) 0px 0px 0px 1px, rgba(0, 0, 0, 0.2) 0px 5px 10px, rgba(0, 0, 0, 0.4) 0px 15px 40px", 13 | }; 14 | 15 | export default shadows; 16 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/sizes.ts: -------------------------------------------------------------------------------- 1 | import { spacing } from "./spacing"; 2 | 3 | const largeSizes = { 4 | max: "max-content", 5 | min: "min-content", 6 | full: "100%", 7 | "3xs": "14rem", 8 | "2xs": "16rem", 9 | xs: "20rem", 10 | sm: "24rem", 11 | md: "28rem", 12 | lg: "32rem", 13 | xl: "36rem", 14 | "2xl": "42rem", 15 | "3xl": "48rem", 16 | "4xl": "56rem", 17 | "5xl": "64rem", 18 | "6xl": "72rem", 19 | "7xl": "80rem", 20 | "8xl": "90rem", 21 | prose: "60ch", 22 | }; 23 | 24 | const container = { 25 | sm: "640px", 26 | md: "768px", 27 | lg: "1024px", 28 | xl: "1280px", 29 | }; 30 | 31 | const sizes = { 32 | ...spacing, 33 | ...largeSizes, 34 | container, 35 | }; 36 | 37 | export default sizes; 38 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/spacing.ts: -------------------------------------------------------------------------------- 1 | export const spacing = { 2 | px: "1px", 3 | 0.5: "calc(var(--base-spacing) * 0.083)", 4 | 1: "calc(var(--base-spacing) * 0.167)", 5 | 1.5: "calc(var(--base-spacing) * 1)", 6 | 2: "calc(var(--base-spacing) * 0.333)", 7 | 2.5: "calc(var(--base-spacing) * 0.417)", 8 | 3: "calc(var(--base-spacing) * 0.5)", 9 | 3.5: "calc(var(--base-spacing) * 0.583)", 10 | 4: "calc(var(--base-spacing) * 0.667)", 11 | 5: "calc(var(--base-spacing) * 0.833)", 12 | 6: "calc(var(--base-spacing) * 1)", 13 | 7: "calc(var(--base-spacing) * 1.167)", 14 | 8: "calc(var(--base-spacing) * 1.333)", 15 | 9: "calc(var(--base-spacing) * 1.5)", 16 | 10: "calc(var(--base-spacing) * 1.667)", 17 | 12: "calc(var(--base-spacing) * 2)", 18 | 14: "calc(var(--base-spacing) * 2.333)", 19 | 16: "calc(var(--base-spacing) * 2.667)", 20 | 20: "calc(var(--base-spacing) * 3.333)", 21 | 24: "calc(var(--base-spacing) * 4)", 22 | 28: "calc(var(--base-spacing) * 4.667)", 23 | 32: "calc(var(--base-spacing) * 5.333)", 24 | 36: "calc(var(--base-spacing) * 6)", 25 | 40: "calc(var(--base-spacing) * 6.667)", 26 | 44: "calc(var(--base-spacing) * 7.333)", 27 | 48: "calc(var(--base-spacing) * 8)", 28 | 52: "calc(var(--base-spacing) * 8.667)", 29 | 56: "calc(var(--base-spacing) * 9.333)", 30 | 60: "calc(var(--base-spacing) * 10)", 31 | 64: "calc(var(--base-spacing) * 10.667)", 32 | 72: "calc(var(--base-spacing) * 12)", 33 | 80: "calc(var(--base-spacing) * 13.333)", 34 | 96: "calc(var(--base-spacing) * 16)", 35 | }; 36 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/transition.ts: -------------------------------------------------------------------------------- 1 | const transitionProperty = { 2 | common: "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform", 3 | colors: "background-color, border-color, color, fill, stroke", 4 | dimensions: "width, height", 5 | position: "left, right, top, bottom", 6 | background: "background-color, background-image, background-position", 7 | }; 8 | 9 | const transitionTimingFunction = { 10 | "ease-in": "cubic-bezier(0.4, 0, 1, 1)", 11 | "ease-out": "cubic-bezier(0, 0, 0.2, 1)", 12 | "ease-in-out": "cubic-bezier(0.4, 0, 0.2, 1)", 13 | }; 14 | 15 | const transitionDuration = { 16 | "ultra-fast": "50ms", 17 | faster: "100ms", 18 | fast: "150ms", 19 | normal: "200ms", 20 | slow: "300ms", 21 | slower: "400ms", 22 | "ultra-slow": "500ms", 23 | }; 24 | 25 | const transition = { 26 | property: transitionProperty, 27 | easing: transitionTimingFunction, 28 | duration: transitionDuration, 29 | }; 30 | 31 | export default transition; 32 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/typography.ts: -------------------------------------------------------------------------------- 1 | const typography = { 2 | letterSpacings: { 3 | tighter: "-0.05em", 4 | tight: "-0.025em", 5 | normal: "0", 6 | wide: "0.025em", 7 | wider: "0.05em", 8 | widest: "0.1em", 9 | }, 10 | 11 | lineHeights: { 12 | normal: "normal", 13 | none: 1, 14 | shorter: 1.25, 15 | short: 1.375, 16 | base: 1.5, 17 | tall: 1.625, 18 | taller: "2", 19 | "3": ".75rem", 20 | "4": "1rem", 21 | "5": "1.25rem", 22 | "6": "1.5rem", 23 | "7": "1.75rem", 24 | "8": "2rem", 25 | "9": "2.25rem", 26 | "10": "2.5rem", 27 | }, 28 | 29 | fontWeights: { 30 | hairline: 100, 31 | thin: 200, 32 | light: 300, 33 | normal: 400, 34 | medium: 500, 35 | semibold: 600, 36 | bold: 700, 37 | extrabold: 800, 38 | black: 900, 39 | }, 40 | 41 | fonts: { 42 | heading: `-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`, 43 | body: `-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`, 44 | mono: `SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace`, 45 | }, 46 | 47 | fontSizes: { 48 | "3xs": "calc(var(--font-size) * 0.45)", 49 | "2xs": "calc(var(--font-size) * 0.625)", 50 | xs: "calc(var(--font-size) * 0.75)", 51 | sm: "calc(var(--font-size) * 0.875)", 52 | md: "calc(var(--font-size) * 1)", 53 | lg: "calc(var(--font-size) * 1.125)", 54 | xl: "calc(var(--font-size) * 1.25)", 55 | "2xl": "calc(var(--font-size) * 1.5)", 56 | "3xl": "calc(var(--font-size) * 1.875)", 57 | "4xl": "calc(var(--font-size) * 2.25)", 58 | "5xl": "calc(var(--font-size) * 3)", 59 | "6xl": "calc(var(--font-size) * 3.75)", 60 | "7xl": "calc(var(--font-size) * 4.5)", 61 | "8xl": "calc(var(--font-size) * 6)", 62 | "9xl": "calc(var(--font-size) * 8)", 63 | }, 64 | }; 65 | 66 | export default typography; 67 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/foundations/z-index.ts: -------------------------------------------------------------------------------- 1 | const zIndices = { 2 | hide: -1, 3 | auto: "auto", 4 | base: 0, 5 | docked: 10, 6 | dropdown: 1000, 7 | sticky: 1100, 8 | banner: 1200, 9 | overlay: 1300, 10 | modal: 1400, 11 | popover: 1500, 12 | skipLink: 1600, 13 | toast: 1700, 14 | tooltip: 1800, 15 | }; 16 | 17 | export default zIndices; 18 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/index.ts: -------------------------------------------------------------------------------- 1 | import { Alert } from "./components/alert"; 2 | import { Button } from "./components"; 3 | import { foundations } from "./foundations"; 4 | import { semanticTokens } from "./semantic-tokens"; 5 | import { styles } from "./styles"; 6 | import type { ThemeConfig, ThemeDirection } from "./theme.types"; 7 | 8 | const direction: ThemeDirection = "ltr"; 9 | 10 | const config: ThemeConfig = { 11 | useSystemColorMode: false, 12 | initialColorMode: "light", 13 | cssVarPrefix: "external", 14 | }; 15 | 16 | const theme = { 17 | semanticTokens, 18 | direction, 19 | ...foundations, 20 | styles, 21 | config, 22 | components: { 23 | Button, 24 | Alert, 25 | }, 26 | }; 27 | 28 | export default theme; 29 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/semantic-tokens.ts: -------------------------------------------------------------------------------- 1 | export const semanticTokens = { 2 | colors: { 3 | "chakra-body-text": { _light: "gray.800", _dark: "whiteAlpha.900" }, 4 | "chakra-body-bg": { _light: "white", _dark: "gray.800" }, 5 | "chakra-border-color": { _light: "gray.200", _dark: "whiteAlpha.300" }, 6 | "chakra-inverse-text": { _light: "white", _dark: "gray.800" }, 7 | "chakra-subtle-bg": { _light: "gray.100", _dark: "gray.700" }, 8 | "chakra-subtle-text": { _light: "gray.600", _dark: "gray.400" }, 9 | "chakra-placeholder-color": { _light: "gray.500", _dark: "whiteAlpha.400" }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/styles.ts: -------------------------------------------------------------------------------- 1 | import { Styles } from "@chakra-ui/theme-tools"; 2 | 3 | export const styles: Styles = {}; 4 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/utils/is-chakra-theme.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from "@chakra-ui/shared-utils"; 2 | import type { ChakraTheme } from "../theme.types"; 3 | 4 | export const requiredChakraThemeKeys: (keyof ChakraTheme)[] = [ 5 | "borders", 6 | "breakpoints", 7 | "colors", 8 | "components", 9 | "config", 10 | "direction", 11 | "fonts", 12 | "fontSizes", 13 | "fontWeights", 14 | "letterSpacings", 15 | "lineHeights", 16 | "radii", 17 | "shadows", 18 | "sizes", 19 | "space", 20 | "styles", 21 | "transition", 22 | "zIndices", 23 | ]; 24 | 25 | export function isChakraTheme(unit: unknown): unit is ChakraTheme { 26 | if (!isObject(unit)) { 27 | return false; 28 | } 29 | 30 | return requiredChakraThemeKeys.every((propertyName) => Object.prototype.hasOwnProperty.call(unit, propertyName)); 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/chakra/utils/run-if-fn.ts: -------------------------------------------------------------------------------- 1 | const isFunction = (value: any): value is Function => typeof value === "function"; 2 | 3 | export function runIfFn(valueOrFn: T | ((...fnArgs: U[]) => T), ...args: U[]): T { 4 | return isFunction(valueOrFn) ? valueOrFn(...args) : valueOrFn; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/theme/colors.ts: -------------------------------------------------------------------------------- 1 | export const colors = { 2 | primary: "var(--color-primary)", 3 | error: "var(--color-text-error)", 4 | }; 5 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/theme/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./colors"; 2 | export * from "./sizes"; 3 | -------------------------------------------------------------------------------- /frontend/src/importer/settings/theme/sizes.ts: -------------------------------------------------------------------------------- 1 | export const sizes = { 2 | icon: { 3 | small: "1em", 4 | medium: "1.142em", 5 | large: "1.71em", 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/importer/stores/theme.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | 4 | type Theme = "dark" | "light"; 5 | type themeStoreType = { 6 | theme: Theme; 7 | setTheme: (theme?: Theme) => void; 8 | }; 9 | 10 | const STORAGE_KEY = "csv-importer-theme"; 11 | 12 | const useThemeStore = create()( 13 | persist( 14 | (set) => ({ 15 | theme: typeof window !== "undefined" ? (localStorage.getItem(STORAGE_KEY) as Theme) : "light", 16 | setTheme: (newTheme) => 17 | set((state) => { 18 | const theme = newTheme || (state.theme === "light" ? "dark" : "light"); 19 | return { theme }; 20 | }), 21 | }), 22 | { 23 | name: STORAGE_KEY, 24 | } 25 | ) 26 | ); 27 | 28 | export default useThemeStore; 29 | -------------------------------------------------------------------------------- /frontend/src/importer/style/fonts.scss: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"); 2 | @import url("https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500&display=swap"); 3 | -------------------------------------------------------------------------------- /frontend/src/importer/style/index.scss: -------------------------------------------------------------------------------- 1 | @import "design-system/colors"; 2 | @import "vars"; 3 | @import "themes/common"; 4 | @import "themes/dark"; 5 | @import "themes/light"; 6 | 7 | .csv-importer { 8 | font-family: var(--font-family-1); 9 | background-color: var(--color-background); 10 | color: var(--color-text); 11 | font-size: var(--font-size); 12 | font-weight: 500; 13 | line-height: 1.5; 14 | 15 | * { 16 | box-sizing: border-box; 17 | } 18 | .container { 19 | max-width: 1300px; 20 | margin: 0 auto; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/importer/style/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin optional-at-root($sel) { 2 | @at-root #{if(not &, $sel, selector-append(&, $sel))} { 3 | @content; 4 | } 5 | } 6 | 7 | @mixin placeholder { 8 | @include optional-at-root("::-webkit-input-placeholder") { 9 | @content; 10 | } 11 | 12 | @include optional-at-root(":-moz-placeholder") { 13 | @content; 14 | } 15 | 16 | @include optional-at-root("::-moz-placeholder") { 17 | @content; 18 | } 19 | 20 | @include optional-at-root(":-ms-input-placeholder") { 21 | @content; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/importer/style/themes/common.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | // BACKGROUND 3 | --color-primary: #{$primary-600}; 4 | --color-primary-hover: #{$primary-700}; 5 | --color-primary-focus: #{$primary-600}; 6 | --color-primary-disabled: #{$primary-200}; 7 | --color-primary-button-disabled: #3f3b55; 8 | 9 | --color-secondary: #{$gray-800}; 10 | --color-secondary-hover: #{$gray-600}; 11 | --color-secondary-focus: #{$gray-800}; 12 | --color-secondary-disabled: #{$gray-700}; 13 | 14 | // TEXT 15 | 16 | --color-text-on-primary: #{$base-white}; 17 | --color-text-on-primary-disabled: #{$gray-500}; 18 | --color-text-on-primary-button-disabled: #{$base-white}; 19 | 20 | --color-text-on-secondary: #{$gray-100}; 21 | --color-text-on-secondary-disabled: #{$gray-600}; 22 | 23 | --color-progress-bar: #{$green-600}; 24 | 25 | --color-success: rgba(18, 183, 106, 0.88); 26 | --color-emphasis: #{$blue-light-500}; 27 | --color-error: rgba(252, 93, 93, 0.88); 28 | --color-attention: rgba(248, 203, 44, 0.88); 29 | 30 | --color-importer-link: #2275d7; 31 | 32 | --blue-light-500: #{$blue-light-500}; // Deprecated 33 | --color-green-ui: var(--color-progress-bar); // Deprecated 34 | --color-green: var(--color-success); // Deprecated 35 | --color-blue: #{$blue-light-500}; // Deprecated 36 | --color-red: rgba(252, 93, 93, 0.88); // Deprecated 37 | --color-yellow: rgba(248, 203, 44, 0.88); // Deprecated 38 | --importer-link: var(--color-importer-link); // Deprecated 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/importer/style/themes/dark.scss: -------------------------------------------------------------------------------- 1 | .CSVImporter-dark { 2 | color-scheme: dark; 3 | 4 | // BACKGROUND 5 | 6 | --color-background: #0e1116; 7 | --color-background-main: var(--color-background); 8 | --color-background-modal: #171a20; 9 | --color-background-modal-hover: #2e323c; 10 | --color-background-modal-veil: #0e1116; 11 | --color-background-modal-shadow: #0e1116; 12 | --color-background-modal-shade: #171a20; 13 | 14 | --color-tertiary: #{$gray-900}; 15 | --color-tertiary-hover: #{$gray-800}; 16 | --color-tertiary-focus: #{$gray-800}; 17 | --color-tertiary-disabled: #{$gray-200}; 18 | 19 | --color-background-menu: #{$gray-900}; 20 | --color-background-menu-hover: #{$gray-800}; 21 | 22 | // TEXT 23 | 24 | --color-text-strong: #{$gray-100}; 25 | --color-text: #{$gray-300}; 26 | --color-text-soft: #{$gray-500}; 27 | 28 | --color-text-on-tertiary: #{$base-white}; 29 | --color-text-on-tertiary-disabled: #{$gray-500}; 30 | 31 | --color-error: #{$error-800}; 32 | --color-text-error: #{$error-500}; 33 | --color-background-error: #{$error-500}; 34 | --color-background-error-hover: #{$error-600}; 35 | --color-background-error-soft: #{$error-200}; 36 | 37 | // INPUT 38 | 39 | --color-input-background: #{$gray-900}; 40 | --color-input-background-soft: #{$gray-800}; 41 | --color-input-border: #{$gray-700}; 42 | --color-input-placeholder: #{$gray-700}; 43 | --color-input-text-disabled: #{$gray-700}; 44 | --color-input-disabled: #171a20; 45 | 46 | --color-border: #{$gray-800}; 47 | 48 | --color-background-small-button-selected: #{$gray-700}; 49 | --color-background-small-button-hover: #{$gray-900}; 50 | --color-text-small-button: $base-white; 51 | 52 | // BUTTON 53 | 54 | --color-button: #{$primary-50}; 55 | --color-button-hover: #{$primary-100}; 56 | --color-button-disabled: #{$primary-100}; 57 | 58 | --color-button-text: #171a20; 59 | --color-button-text-disabled: lighter(#171a20, 10); 60 | 61 | --color-button-border: transparent; 62 | 63 | // BORDER 64 | 65 | --color-border: #{$gray-700}; 66 | --color-border-soft: #{$gray-800}; 67 | 68 | // ICONS 69 | 70 | --color-icon: #{$gray-300}; 71 | 72 | // SHADOW 73 | 74 | --color-bisel: rgba(255, 255, 255, 0.05); 75 | 76 | // BRAND 77 | 78 | --color-csv-import-text: var(--color-text); 79 | 80 | // STEPPER 81 | 82 | --color-stepper: #{$gray-cool-800}; 83 | --color-stepper-active: #{$success-300}; 84 | } 85 | -------------------------------------------------------------------------------- /frontend/src/importer/style/themes/light.scss: -------------------------------------------------------------------------------- 1 | .CSVImporter-light { 2 | color-scheme: light; 3 | 4 | // BACKGROUND 5 | 6 | --color-background: #{$gray-100}; 7 | --color-background-main: #{$base-white}; 8 | --color-background-modal: #{$base-white}; 9 | --color-background-modal-hover: #{$base-white}; 10 | --color-background-modal-veil: #0e1116; 11 | --color-background-modal-shadow: transparent; 12 | --color-background-modal-shade: #{$gray-100}; 13 | 14 | --color-tertiary: #{$base-white}; 15 | --color-tertiary-hover: #{$gray-100}; 16 | --color-tertiary-focus: #{$base-white}; 17 | --color-tertiary-disabled: #{$gray-200}; 18 | 19 | --color-background-menu: #{$base-white}; 20 | --color-background-menu-hover: #{$gray-100}; 21 | 22 | // TEXT 23 | 24 | --color-text-strong: #{$gray-900}; 25 | --color-text: #{$gray-800}; 26 | --color-text-soft: #{$gray-500}; 27 | 28 | --color-text-on-tertiary: #{$gray-700}; 29 | --color-text-on-tertiary-disabled: #{$gray-500}; 30 | 31 | --color-error: #{$error-200}; 32 | --color-text-error: #{$error-500}; 33 | --color-background-error: #{$error-500}; 34 | --color-background-error-hover: #{$error-600}; 35 | --color-background-error-soft: #{$error-200}; 36 | 37 | // INPUT 38 | 39 | --color-input-background: #{$base-white}; 40 | --color-input-background-soft: #{$gray-300}; 41 | --color-input-border: #{$gray-700}; 42 | --color-input-placeholder: #{$gray-700}; 43 | --color-input-text-disabled: #{$gray-700}; 44 | --color-input-disabled: #{$gray-50}; 45 | 46 | --color-border: #{$gray-800}; 47 | 48 | --color-background-small-button-selected: #{$gray-100}; 49 | --color-background-small-button-hover: #{$gray-50}; 50 | --color-text-small-button: var(--color-text); 51 | 52 | // BUTTON (default) 53 | 54 | --color-button: #{$base-white}; 55 | --color-button-hover: #{$gray-100}; 56 | --color-button-disabled: #{$gray-25}; 57 | 58 | --color-button-text: var(--color-text-soft); 59 | --color-button-text-disabled: #{$gray-300}; 60 | 61 | --color-button-border: #{$gray-300}; 62 | 63 | // BORDER 64 | 65 | --color-border: #{$gray-300}; 66 | --color-border-soft: #{$gray-200}; 67 | 68 | // ICONS 69 | 70 | --color-icon: #{$gray-blue-900}; 71 | 72 | // SHADOW 73 | 74 | --color-bisel: rgba(0, 0, 0, 0.05); 75 | 76 | // BRAND 77 | 78 | --color-csv-import-text: #130638; 79 | 80 | // STEPPER 81 | 82 | --color-stepper: #{$gray-cool-300}; 83 | --color-stepper-active: #{$success-300}; 84 | } 85 | -------------------------------------------------------------------------------- /frontend/src/importer/style/vars.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | // DIMENSIONS 3 | 4 | // margins and paddings 5 | --base-spacing: 24px; 6 | --m-xxxxs: calc(var(--base-spacing) / 5); 7 | --m-xxxs: calc(var(--base-spacing) / 4); 8 | --m-xxs: calc(var(--base-spacing) / 3); 9 | --m-xs: calc(var(--base-spacing) / 2); 10 | --m-s: calc(var(--base-spacing) * 2 / 3); 11 | --m: var(--base-spacing); 12 | --m-mm: calc(var(--base-spacing) * 3 / 2); 13 | --m-l: calc(var(--base-spacing) * 5 / 3); 14 | --m-xl: calc(var(--base-spacing) * 2); 15 | --m-xxl: calc(var(--base-spacing) * 5 / 2); 16 | --m-xxxl: calc(var(--base-spacing) * 3); 17 | 18 | // FONTS 19 | 20 | --font-size-xs: calc(var(--font-size) * 16 / 17); 21 | --font-size-s: calc(var(--font-size) * 13 / 14); 22 | --font-size: 0.875rem; 23 | --font-size-l: calc(var(--font-size) * 8 / 7); 24 | --font-size-xl: calc(var(--font-size) * 9 / 7); 25 | --font-size-xxl: calc(var(--font-size) * 12 / 7); 26 | --font-size-xxxl: calc(var(--font-size) * 18 / 7); 27 | --font-size-h: calc(var(--font-size) * 24 / 7); 28 | 29 | --font-family: "Inter", sans-serif; 30 | --font-family-1: var(--font-family); 31 | --font-family-2: "Laxan", sans-serif; 32 | 33 | // BORDERS 34 | 35 | --border-radius: 4px; 36 | --border-radius-1: var(--border-radius); 37 | --border-radius-2: calc(var(--border-radius) * 2); 38 | --border-radius-3: calc(var(--border-radius) * 3); 39 | --border-radius-4: calc(var(--border-radius) * 4); 40 | --border-radius-5: calc(var(--border-radius) * 5); 41 | --border-radius-r: 50%; 42 | 43 | // TRANSITIONS 44 | 45 | --fast: 0.3s; 46 | --speed: 0.4s; 47 | --slow: 0.9s; 48 | --ease: ease-out; 49 | --transition-ui: background-color var(--fast) var(--ease), border-color var(--fast) var(--ease), opacity var(--fast) var(--ease), 50 | transform var(--fast) var(--ease), color var(--fast) var(--ease); 51 | 52 | // BLURRED 53 | 54 | --blurred: 5px; 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/importer/types/index.ts: -------------------------------------------------------------------------------- 1 | export type Template = { 2 | columns: TemplateColumn[]; 3 | }; 4 | 5 | export type TemplateColumn = { 6 | name: string; 7 | key?: string; // Allow key to be potentially undefined initially 8 | description?: string; 9 | required?: boolean; 10 | data_type?: string; 11 | validation_format?: string; 12 | type?: string; // For backwards compatibility 13 | }; 14 | 15 | export type UploadColumn = { 16 | index: number; 17 | name: string; 18 | sample_data: string[]; 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/importer/utils/classes.ts: -------------------------------------------------------------------------------- 1 | const classes = (a: any[], separator = " "): string => 2 | a 3 | .filter((c) => c) 4 | .map((c) => c.toString().trim()) 5 | .join(separator); 6 | 7 | export default classes; 8 | -------------------------------------------------------------------------------- /frontend/src/importer/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | function debounce any>(func: F, wait: number, immediate = false): (...args: Parameters) => void { 2 | let timeout: ReturnType | null; 3 | 4 | return function executedFunction(this: any, ...args: Parameters) { 5 | // eslint-disable-next-line @typescript-eslint/no-this-alias 6 | const context: any = this; 7 | 8 | const later = function () { 9 | timeout = null; 10 | if (!immediate) func.apply(context, args); 11 | }; 12 | 13 | const callNow = immediate && !timeout; 14 | 15 | if (timeout) clearTimeout(timeout); 16 | 17 | timeout = setTimeout(later, wait); 18 | 19 | if (callNow) func.apply(context, args); 20 | }; 21 | } 22 | 23 | export default debounce; 24 | -------------------------------------------------------------------------------- /frontend/src/importer/utils/getStringLengthOfChildren.ts: -------------------------------------------------------------------------------- 1 | import { isValidElement } from "react"; 2 | 3 | export default function getStringLengthOfChildren(children: React.ReactNode): number { 4 | if (typeof children === "string") return children.length; 5 | 6 | if (Array.isArray(children)) return children.reduce((sum, child) => sum + getStringLengthOfChildren(child), 0); 7 | 8 | // If child is a React element, process its children recursively 9 | if (isValidElement(children)) return getStringLengthOfChildren(children.props.children); 10 | 11 | // If none of the above, return 0 12 | return 0; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/importer/utils/stringSimilarity.ts: -------------------------------------------------------------------------------- 1 | export default function stringsSimilarity(s1: string, s2: string): number { 2 | const words = s1.split(" "); 3 | const words2 = s2.split(" "); 4 | 5 | const highestSimilarity = words.reduce((acc, word) => { 6 | const highestSimilarity = words2.reduce((acc2, word2) => { 7 | const wordSimilarity = similarity(word, word2); 8 | return wordSimilarity > acc2 ? wordSimilarity : acc2; 9 | }, 0); 10 | 11 | return highestSimilarity > acc ? highestSimilarity : acc; 12 | }, 0); 13 | 14 | return highestSimilarity; 15 | } 16 | 17 | // From https://stackoverflow.com/a/36566052 18 | 19 | function similarity(s1: string, s2: string): number { 20 | let longer: string = s1; 21 | let shorter: string = s2; 22 | if (s1.length < s2.length) { 23 | longer = s2; 24 | shorter = s1; 25 | } 26 | const longerLength: number = longer.length; 27 | if (longerLength === 0) { 28 | return 1.0; 29 | } 30 | return (longerLength - editDistance(longer, shorter)) / parseFloat(longerLength.toString()); 31 | } 32 | 33 | function editDistance(s1: string, s2: string): number { 34 | s1 = s1.toLowerCase(); 35 | s2 = s2.toLowerCase(); 36 | 37 | const costs: number[] = new Array(s2.length + 1); 38 | for (let i = 0; i <= s1.length; i++) { 39 | let lastValue: number = i; 40 | for (let j = 0; j <= s2.length; j++) { 41 | if (i === 0) costs[j] = j; 42 | else { 43 | if (j > 0) { 44 | let newValue: number = costs[j - 1]; 45 | if (s1.charAt(i - 1) !== s2.charAt(j - 1)) newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1; 46 | costs[j - 1] = lastValue; 47 | lastValue = newValue; 48 | } 49 | } 50 | } 51 | if (i > 0) costs[s2.length] = lastValue; 52 | } 53 | return costs[s2.length]; 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/importer/utils/template.ts: -------------------------------------------------------------------------------- 1 | import { Template, TemplateColumn } from "../types"; 2 | import { parseObjectOrStringJSONToRecord, sanitizeKey } from "./utils"; 3 | 4 | export function convertRawTemplate(rawTemplate?: Record | string): [Template | null, string | null] { 5 | const template = parseObjectOrStringJSONToRecord("template", rawTemplate); 6 | 7 | if (!template || Object.keys(template).length === 0) { 8 | return [null, "The parameter 'template' is required. Please check the documentation for more details."]; 9 | } 10 | 11 | const columnData = template["columns"]; 12 | if (!columnData) { 13 | return [null, "Invalid template: No columns provided"]; 14 | } 15 | if (!Array.isArray(columnData)) { 16 | return [null, "Invalid template: columns should be an array of objects"]; 17 | } 18 | 19 | const seenKeys: Record = {}; 20 | const columns: TemplateColumn[] = []; 21 | 22 | for (let i = 0; i < columnData.length; i++) { 23 | const item = columnData[i]; 24 | 25 | if (typeof item !== "object") { 26 | return [null, `Invalid template: Each item in columns should be an object (check column ${i})`]; 27 | } 28 | 29 | const name: string = item.name || ""; 30 | let key: string = item.key || ""; 31 | const description: string = item.description || ""; 32 | const required: boolean = item.required || false; 33 | const data_type: string = item.data_type || ""; 34 | const validation_format: string = item.validation_format || ""; 35 | const type: string = item.type || data_type || ""; 36 | 37 | if (name === "") { 38 | return [null, `Invalid template: The parameter "name" is required for each column (check column ${i})`]; 39 | } 40 | if (key === "") { 41 | key = sanitizeKey(name); 42 | } 43 | if (seenKeys[key]) { 44 | return [null, `Invalid template: Duplicate keys are not allowed (check column ${i})`]; 45 | } 46 | 47 | seenKeys[key] = true; 48 | 49 | columns.push({ 50 | name, 51 | key, 52 | description, 53 | required, 54 | data_type, 55 | validation_format, 56 | type 57 | } as TemplateColumn); 58 | } 59 | 60 | if (columns.length === 0) { 61 | return [null, "Invalid template: No columns were provided"]; 62 | } 63 | 64 | return [{ columns }, null]; 65 | } 66 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Basic styling for the ImportCSV application */ 6 | 7 | body { 8 | margin: 0; 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 10 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 11 | sans-serif; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | background-color: #f5f5f5; 15 | } 16 | 17 | .app-container { 18 | max-width: 1200px; 19 | margin: 0 auto; 20 | padding: 20px; 21 | } 22 | 23 | .app-header { 24 | display: flex; 25 | justify-content: space-between; 26 | align-items: center; 27 | margin-bottom: 30px; 28 | padding-bottom: 15px; 29 | border-bottom: 1px solid #e0e0e0; 30 | } 31 | 32 | .app-header h1 { 33 | margin: 0; 34 | color: #333; 35 | } 36 | 37 | .user-info { 38 | display: flex; 39 | align-items: center; 40 | gap: 15px; 41 | } 42 | 43 | .app-main { 44 | background-color: white; 45 | padding: 20px; 46 | border-radius: 8px; 47 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 48 | } 49 | 50 | .schema-selector { 51 | display: flex; 52 | align-items: center; 53 | gap: 15px; 54 | margin-bottom: 20px; 55 | } 56 | 57 | select { 58 | padding: 8px 12px; 59 | border-radius: 4px; 60 | border: 1px solid #ccc; 61 | min-width: 200px; 62 | } 63 | 64 | button { 65 | padding: 8px 16px; 66 | background-color: #0066cc; 67 | color: white; 68 | border: none; 69 | border-radius: 4px; 70 | cursor: pointer; 71 | font-weight: 500; 72 | } 73 | 74 | button:hover { 75 | background-color: #0055aa; 76 | } 77 | 78 | button:disabled { 79 | background-color: #cccccc; 80 | cursor: not-allowed; 81 | } 82 | 83 | .loading { 84 | text-align: center; 85 | padding: 20px; 86 | color: #666; 87 | } 88 | 89 | .error-message { 90 | background-color: #ffebee; 91 | color: #c62828; 92 | padding: 10px 15px; 93 | border-radius: 4px; 94 | margin-bottom: 20px; 95 | } 96 | 97 | .import-job-status { 98 | margin-top: 20px; 99 | padding: 15px; 100 | background-color: #e8f5e9; 101 | border-radius: 4px; 102 | } 103 | 104 | .login-container { 105 | max-width: 400px; 106 | margin: 100px auto; 107 | padding: 30px; 108 | background-color: white; 109 | border-radius: 8px; 110 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 111 | } 112 | 113 | .login-container h2 { 114 | margin-top: 0; 115 | margin-bottom: 20px; 116 | text-align: center; 117 | color: #333; 118 | } 119 | 120 | .form-group { 121 | margin-bottom: 15px; 122 | } 123 | 124 | .form-group label { 125 | display: block; 126 | margin-bottom: 5px; 127 | font-weight: 500; 128 | } 129 | 130 | .form-group input { 131 | width: 100%; 132 | padding: 8px 12px; 133 | border-radius: 4px; 134 | border: 1px solid #ccc; 135 | box-sizing: border-box; 136 | } 137 | 138 | .login-container button { 139 | width: 100%; 140 | padding: 10px; 141 | margin-top: 10px; 142 | } 143 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /frontend/src/index.ts: -------------------------------------------------------------------------------- 1 | import CSVImporter from "./components/CSVImporter"; 2 | 3 | export { CSVImporter }; 4 | -------------------------------------------------------------------------------- /frontend/src/js.tsx: -------------------------------------------------------------------------------- 1 | import { createRef } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import CSVImporter from "./components/CSVImporter"; 4 | import { CSVImporterProps } from "./types"; 5 | 6 | type CreateImporterProps = CSVImporterProps & { domElement?: Element }; 7 | 8 | export function createCSVImporter(props: CreateImporterProps) { 9 | const ref = createRef(); 10 | const domElement = props.domElement || document.body; 11 | 12 | ReactDOM.render(, domElement); 13 | 14 | return { 15 | instance: ref.current, 16 | showModal: () => { 17 | ref.current?.showModal?.(); 18 | }, 19 | closeModal: () => { 20 | ref.current?.close?.(); 21 | }, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/settings/defaults.ts: -------------------------------------------------------------------------------- 1 | import { CSVImporterProps } from "../types"; 2 | 3 | const defaults: CSVImporterProps = { 4 | darkMode: true, 5 | onComplete: (data) => console.log("onComplete", data), 6 | isModal: true, 7 | modalCloseOnOutsideClick: true, 8 | }; 9 | 10 | export default defaults; 11 | -------------------------------------------------------------------------------- /frontend/src/styles.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.scss" { 2 | const content: { [className: string]: string }; 3 | export = content; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Resource } from "i18next"; 2 | 3 | type ModalParams = { 4 | isModal?: boolean; 5 | modalIsOpen?: boolean; 6 | modalOnCloseTriggered?: () => void; 7 | modalCloseOnOutsideClick?: boolean; 8 | }; 9 | 10 | export type CSVImporterProps = { 11 | darkMode?: boolean; 12 | primaryColor?: string; 13 | className?: string; // Keep className as it's often used for styling wrappers 14 | onComplete?: (data: any) => void; 15 | waitOnComplete?: boolean; 16 | customStyles?: Record | string; 17 | showDownloadTemplateButton?: boolean; 18 | skipHeaderRowSelection?: boolean; 19 | language?: string; 20 | customTranslations?: Resource; 21 | importerKey?: string; // Key of the importer from the admin/backend 22 | backendUrl?: string; // URL of the backend API 23 | user?: Record; // User details to identify the user in webhooks 24 | metadata?: Record; // Additional data to associate with the import 25 | // You might want to explicitly allow specific data-* attributes if needed 26 | // 'data-testid'?: string; 27 | } & ModalParams; 28 | -------------------------------------------------------------------------------- /frontend/src/utils/classes.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | /** 5 | * Merges class names using clsx and tailwind-merge 6 | * This is a utility function used by shadcn/ui components 7 | */ 8 | export function cn(...inputs: ClassValue[]) { 9 | return twMerge(clsx(inputs)); 10 | } -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class", '[data-theme="dark"]'], 4 | content: [ 5 | "./src/**/*.{js,jsx,ts,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | colors: { 10 | border: "var(--color-border)", 11 | input: "var(--color-border)", 12 | ring: "var(--color-border)", 13 | background: "var(--color-background)", 14 | foreground: "var(--color-text)", 15 | primary: { 16 | DEFAULT: "var(--color-primary)", 17 | foreground: "var(--color-primary-foreground)", 18 | }, 19 | secondary: { 20 | DEFAULT: "var(--color-secondary)", 21 | foreground: "var(--color-secondary-foreground)", 22 | }, 23 | destructive: { 24 | DEFAULT: "var(--color-error)", 25 | foreground: "var(--color-error-foreground)", 26 | }, 27 | muted: { 28 | DEFAULT: "var(--color-muted)", 29 | foreground: "var(--color-muted-foreground)", 30 | }, 31 | accent: { 32 | DEFAULT: "var(--color-accent)", 33 | foreground: "var(--color-accent-foreground)", 34 | }, 35 | }, 36 | borderRadius: { 37 | lg: "var(--border-radius-3)", 38 | md: "var(--border-radius-2)", 39 | sm: "var(--border-radius-1)", 40 | }, 41 | keyframes: { 42 | "accordion-down": { 43 | from: { height: 0 }, 44 | to: { height: "var(--radix-accordion-content-height)" }, 45 | }, 46 | "accordion-up": { 47 | from: { height: "var(--radix-accordion-content-height)" }, 48 | to: { height: 0 }, 49 | }, 50 | }, 51 | animation: { 52 | "accordion-down": "accordion-down 0.2s ease-out", 53 | "accordion-up": "accordion-up 0.2s ease-out", 54 | }, 55 | }, 56 | }, 57 | plugins: [], 58 | } -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "declaration": true, 5 | "declarationDir": "build", 6 | "module": "esnext", 7 | "target": "es5", 8 | "lib": ["es6", "dom", "es2016", "es2017"], 9 | "sourceMap": true, 10 | "moduleResolution": "node", 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "plugins": [{ "name": "typescript-plugin-css-modules" }], 14 | "allowJs": true, 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx" 23 | }, 24 | "include": ["src"] 25 | } 26 | --------------------------------------------------------------------------------