Revert "Convert frontend to React JS and add S3 deployment workflows"
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 35s
This reverts commit e10f9829ee.
|
|
@ -1,39 +0,0 @@
|
|||
name: Deploy Frontend Beta (S3)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- beta
|
||||
|
||||
jobs:
|
||||
deploy-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Install Dependencies and Build
|
||||
env:
|
||||
VITE_API_BASE_URL: https://betaapicsa.tradhox.com/api
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Deploy to AWS S3 & Invalidate CloudFront
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ap-south-2
|
||||
S3_BUCKET: ${{ secrets.AWS_S3_BUCKET_BETA || 'betacsa' }}
|
||||
CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID_BETA || 'E8KO4T7M0MRBS' }}
|
||||
run: |
|
||||
apk add --no-cache aws-cli
|
||||
if [ -z "$AWS_ACCESS_KEY_ID" ]; then
|
||||
unset AWS_ACCESS_KEY_ID
|
||||
unset AWS_SECRET_ACCESS_KEY
|
||||
fi
|
||||
aws s3 sync frontend/dist/ s3://${S3_BUCKET} --delete --region ap-south-2
|
||||
if [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ]; then
|
||||
aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_DISTRIBUTION_ID} --paths "/*" --region ap-south-2
|
||||
fi
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
name: Deploy Frontend Production (S3)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Install Dependencies and Build
|
||||
env:
|
||||
VITE_API_BASE_URL: https://apicsa.tradhox.com/api
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Deploy to AWS S3 & Invalidate CloudFront
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ap-south-2
|
||||
S3_BUCKET: ${{ secrets.AWS_S3_BUCKET_PRODUCTION || 'csawebsite' }}
|
||||
CLOUDFRONT_DISTRIBUTION_ID: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID_PRODUCTION || 'E2G2C75ZGRMFVG' }}
|
||||
run: |
|
||||
apk add --no-cache aws-cli
|
||||
if [ -z "$AWS_ACCESS_KEY_ID" ]; then
|
||||
unset AWS_ACCESS_KEY_ID
|
||||
unset AWS_SECRET_ACCESS_KEY
|
||||
fi
|
||||
aws s3 sync frontend/dist/ s3://${S3_BUCKET} --delete --region ap-south-2
|
||||
if [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ]; then
|
||||
aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_DISTRIBUTION_ID} --paths "/*" --region ap-south-2
|
||||
fi
|
||||
4
.gitignore
vendored
|
|
@ -57,9 +57,11 @@ site/
|
|||
|
||||
# Node.js / Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.svelte-kit/
|
||||
frontend/build/
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
frontend/package-lock.json
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
|
|
|||
30
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# The compose frontend service builds with `context: ./frontend`, and Docker
|
||||
# reads .dockerignore from the context root, so the repo-root .dockerignore has
|
||||
# never applied to this build. Without this file the whole of node_modules and
|
||||
# .svelte-kit ships to the daemon on every build.
|
||||
|
||||
# Secrets. Load-bearing, not tidiness: the builder stage copies the source in,
|
||||
# and vite.config.js calls loadEnv(), so a real .env in the context would be
|
||||
# read at build time. PUBLIC_SENTRY_DSN in particular flips autoUploadSourceMaps
|
||||
# on, and the build then fails looking for a SENTRY_AUTH_TOKEN that is not there.
|
||||
# Every PUBLIC_ value this app reads comes from $env/dynamic/public, which is
|
||||
# resolved at RUNTIME, so nothing here needs an .env to build correctly.
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Install and build outputs. Regenerated inside the image; copying the host's
|
||||
# would also drop a linux-x64 image on top of whatever the host built.
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
|
||||
# Never needed by a build
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
.playwright-mcp
|
||||
coverage
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
@ -1 +1,29 @@
|
|||
VITE_API_BASE_URL=https://betaapicsa.tradhox.com/api
|
||||
# Google OAuth (client_id only - secret is on backend)
|
||||
GOOGLE_CLIENT_ID="your-google-client-id-here"
|
||||
GOOGLE_LOGIN_DOMAIN="http://localhost:5181"
|
||||
|
||||
# Django API URL (PUBLIC_ prefix makes it available in both server and browser)
|
||||
PUBLIC_DJANGO_API_URL=http://localhost:8000
|
||||
|
||||
# Sentry (only set in production, omit locally to disable Sentry)
|
||||
# PUBLIC_SENTRY_DSN="https://your-dsn.ingest.sentry.io/your-project"
|
||||
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Relayed visitor addresses (optional locally; deploy-frontend.sh requires them
|
||||
# in hosted production). Several pages reach the API from this server; with
|
||||
# RELAY_SECRET set they also send the visitor's address, signed with it, so
|
||||
# each visitor gets their own per-IP throttle bucket and recorded address. Must equal RELAY_SECRET on the Django
|
||||
# API and on bottlecrm.io. Private: never prefix it with PUBLIC_.
|
||||
# RELAY_SECRET=
|
||||
#
|
||||
# Behind nginx in production, adapter-node only knows the visitor if told which
|
||||
# header nginx writes. Without these, getClientAddress() is nginx itself.
|
||||
# ADDRESS_HEADER=X-Forwarded-For
|
||||
# XFF_DEPTH=1
|
||||
#
|
||||
# adapter-node binds 0.0.0.0 by default. Bind it to loopback in production so
|
||||
# only nginx can reach it: reachable directly, a caller writes its own
|
||||
# X-Forwarded-For and the relay signs it as the visitor.
|
||||
# HOST=127.0.0.1
|
||||
|
|
|
|||
48
frontend/.gitignore
vendored
|
|
@ -1,24 +1,30 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
generated/*
|
||||
src/generated/*
|
||||
.github/prompts/*
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
|
|
|||
1
frontend/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
14
frontend/.prettierignore
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Local session artifacts
|
||||
.remember/
|
||||
|
||||
# Generated API schema. drf-spectacular writes YAML, and the file name says
|
||||
# .json, so prettier picks the JSON parser and dies on line 1 instead of
|
||||
# reporting a formatting diff. That aborts `pnpm run lint` before eslint runs.
|
||||
openapi.json
|
||||
16
frontend/.prettierrc
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
87
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# One file, two outputs: the `dev` stage docker-compose runs, and the default
|
||||
# `runtime` stage a release publishes. They were going to be two files, which is
|
||||
# how the pnpm pin in here already drifted from the one in .github/workflows
|
||||
# (11.17.0 against 10). A single base stage means there is one version to bump.
|
||||
#
|
||||
# RUNTIME CONFIGURATION
|
||||
#
|
||||
# Every value this app reads from the environment is declared in src/env.js and
|
||||
# read per process at startup, not at build time. So one image serves any
|
||||
# deployment and nothing below bakes a hostname in:
|
||||
#
|
||||
# PUBLIC_DJANGO_API_URL required. The Django API origin, no trailing /api.
|
||||
# PUBLIC_SENTRY_DSN optional. Unset disables Sentry (`enabled: !!dsn`).
|
||||
# PORT defaults to 3000. HOST already defaults to 0.0.0.0.
|
||||
# PROTOCOL_HEADER only for plain http. See below.
|
||||
# BODY_SIZE_LIMIT set to 6M below. adapter-node 6 defaults to 512K, which
|
||||
# would refuse a spreadsheet import (the API caps files
|
||||
# at 5 MB) with a plain-text 413.
|
||||
#
|
||||
# The origin check is the one that fails quietly. SvelteKit compares the Origin
|
||||
# header of every form POST with the origin the server thinks it serves, which
|
||||
# adapter-node 6 builds from the request's Host header and the scheme `https`.
|
||||
# (The ORIGIN variable that used to override this was removed in SvelteKit 3,
|
||||
# so setting it does nothing.) Behind a TLS proxy that forwards Host, such as
|
||||
# nginx with `proxy_set_header Host $host`, that is already right. If users
|
||||
# reach the app over plain http, set PROTOCOL_HEADER=x-forwarded-proto and have
|
||||
# the proxy send that header. Otherwise every form POST is answered `403
|
||||
# Cross-site POST form submissions are forbidden` while GETs still work, so the
|
||||
# app looks healthy while all its form actions reject.
|
||||
|
||||
FROM node:24-slim AS base
|
||||
# Pinned so image builds are reproducible.
|
||||
RUN corepack enable && corepack prepare pnpm@11.17.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Manifests only, so this layer caches on the lockfile rather than on source.
|
||||
# pnpm-workspace.yaml carries the install settings (onlyBuiltDependencies,
|
||||
# allowBuilds) that pnpm 10+ no longer reads from package.json. Without it
|
||||
# @sentry/cli's postinstall is silently skipped and its binary never lands.
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
# The compose target. Source and node_modules arrive as mounts, so this stage
|
||||
# deliberately copies no source of its own.
|
||||
FROM deps AS dev
|
||||
EXPOSE 5181
|
||||
CMD ["pnpm", "dev", "--host", "0.0.0.0"]
|
||||
|
||||
|
||||
FROM deps AS builder
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
# Runtime dependencies only. `build/` is NOT self-contained: SvelteKit keeps
|
||||
# instrumentation.server.js external so OpenTelemetry can hook the process
|
||||
# before anything else loads, and it imports @sentry/sveltekit by name. Without
|
||||
# this stage the server dies at boot on ERR_MODULE_NOT_FOUND.
|
||||
#
|
||||
# --ignore-scripts because the `prepare` script shells out to svelte-kit, which
|
||||
# is a devDependency and therefore absent here. package.json already tolerates
|
||||
# that with `|| echo ''`; skipping it outright keeps the failure out of the log.
|
||||
FROM base AS prod-deps
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
RUN pnpm install --prod --frozen-lockfile --ignore-scripts
|
||||
|
||||
|
||||
FROM node:24-slim AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV BODY_SIZE_LIMIT=6M
|
||||
|
||||
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=node:node /app/build ./build
|
||||
# The server reads its own name and version from here on startup.
|
||||
COPY --chown=node:node package.json ./
|
||||
|
||||
# Present in the node images already. The server binds 3000, so it needs no
|
||||
# privileged port and no reason to be root.
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build/index.js"]
|
||||
|
|
@ -1,16 +1,41 @@
|
|||
# React + Vite
|
||||
# BottleCRM web app
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
The SvelteKit (Svelte 5) web client for BottleCRM. It has no database of its own: every page reads
|
||||
and writes through the Django REST API in [`../backend`](../backend), the same API the Flutter app
|
||||
in [`../mobile`](../mobile) uses. For what BottleCRM is, see the [project README](../README.md).
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## Run it locally
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
Start the backend first (see [Development setup](../docs/contributing/development-setup.md)), then:
|
||||
|
||||
## React Compiler
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env # PUBLIC_DJANGO_API_URL points at the backend, http://localhost:8000 by default
|
||||
pnpm install
|
||||
pnpm run dev # http://localhost:5181
|
||||
```
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
The browser calls the API cross-origin, so the backend's `CORS_ALLOWED_ORIGINS` must include
|
||||
`http://localhost:5181`. A missing entry shows up as a CORS error, not a 404.
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
## Checks
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||
```bash
|
||||
pnpm run check # svelte-check type checking
|
||||
pnpm run lint # prettier + eslint
|
||||
pnpm run test # vitest
|
||||
pnpm run build # production build (adapter-node)
|
||||
```
|
||||
|
||||
## Access control
|
||||
|
||||
Org membership and role (`USER` or `ADMIN`) come from the server, and the backend is the only place
|
||||
they are enforced; anything the UI hides is a convenience, not a control. Platform super admin is
|
||||
the `is_superuser` flag on the user record, granted explicitly and never derived from an email
|
||||
address.
|
||||
|
||||
## More
|
||||
|
||||
- [Manual setup](../docs/getting-started/manual-setup.md) and [Docker quick start](../docs/getting-started/docker-quick-start.md)
|
||||
- [Contributing](../CONTRIBUTING.md)
|
||||
- License: [MIT](../LICENSE)
|
||||
|
|
|
|||
16
frontend/components.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "neutral"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks"
|
||||
},
|
||||
"typescript": false
|
||||
}
|
||||
31
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import prettier from 'eslint-config-prettier';
|
||||
import js from '@eslint/js';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default [
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
}
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'no-useless-assignment': 'warn',
|
||||
'svelte/require-each-key': 'warn',
|
||||
'svelte/no-navigation-without-resolve': 'warn',
|
||||
'svelte/prefer-svelte-reactivity': 'warn',
|
||||
'svelte/prefer-writable-derived': 'warn'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DigiHox CRM - Admin Portal</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
11
frontend/jsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "$app/tsconfig",
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"strictNullChecks": false
|
||||
},
|
||||
"include": ["src", "vite.config.js", "test"]
|
||||
}
|
||||
1328
frontend/package-lock.json
generated
|
|
@ -1,26 +1,57 @@
|
|||
{
|
||||
"name": "crmfrontend",
|
||||
"name": "svelte-crm",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"react": "^19.2.8",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2"
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"oxlint": "^1.75.0",
|
||||
"vite": "^8.2.0"
|
||||
"@eslint/compat": "^2.1.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@lucide/svelte": "^1.57.0",
|
||||
"@sveltejs/adapter-node": "^6.0.0",
|
||||
"@sveltejs/kit": "^3.0.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.6.5",
|
||||
"bits-ui": "^2.19.5",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.23.1",
|
||||
"globals": "^17.13.0",
|
||||
"prettier": "^3.9.10",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"svelte": "^5.57.2",
|
||||
"svelte-check": "^4.7.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.4",
|
||||
"vitest": "^5.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/sveltekit": "^11.7.0",
|
||||
"axios": "^1.20.0",
|
||||
"clsx": "^2.1.1",
|
||||
"libphonenumber-js": "^1.13.15",
|
||||
"svelte-dnd-action": "^0.9.80",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"tailwind-variants": "^3.3.1",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"imports": {
|
||||
"#lib": "./src/lib/index.js",
|
||||
"#lib/*": "./src/lib/*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3721
frontend/pnpm-lock.yaml
Normal file
14
frontend/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
allowBuilds:
|
||||
'@sentry/cli': true
|
||||
onlyBuiltDependencies:
|
||||
- '@sentry/cli'
|
||||
overrides:
|
||||
# GHSA-rgw5-rvv9-x895 (high): DoS via unbounded intermediate arrays.
|
||||
# Only reachable through dev tooling (eslint, @sentry/vite-plugin), but the
|
||||
# transitive parents still ask for a vulnerable range, so force the patch.
|
||||
'brace-expansion@>=4.0.0 <5.0.9': '^5.0.9'
|
||||
# GHSA-rj75-hqrm-r3gf (moderate): quadratic parsing of flat selectors.
|
||||
# Build-time only: @tailwindcss/typography 0.5.20 pins 6.0.10 exactly. 7.0's
|
||||
# one breaking change makes insertion during iteration safe; typography only
|
||||
# removes nodes from a copied array, and the built CSS is byte-identical.
|
||||
'postcss-selector-parser@<7.1.6': '^7.1.6'
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
|
|
@ -1,24 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
|
|
@ -1,42 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# DigiHox CRM Service Launcher
|
||||
# Runs both the React frontend and Django backend concurrently
|
||||
|
||||
FRONTEND_DIR="/home/vignesh/github/crmfrontend"
|
||||
BACKEND_DIR="/home/vignesh/github/crmbackend"
|
||||
|
||||
echo "=========================================="
|
||||
echo "🚀 Starting DigiHox CRM Local Dev Servers..."
|
||||
echo "=========================================="
|
||||
|
||||
# Function to handle exit cleanup
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "🛑 Shutting down servers gracefully..."
|
||||
echo "=========================================="
|
||||
kill "$BACKEND_PID" "$FRONTEND_PID" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Trap SIGINT (Ctrl+C) and SIGTERM
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
# 1. Start Django Backend
|
||||
echo "Starting Django REST Backend on Port 8002..."
|
||||
cd "$BACKEND_DIR" || exit 1
|
||||
.venv/bin/python manage.py runserver 8002 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Wait a second for Django setup log lines
|
||||
sleep 1.5
|
||||
|
||||
# 2. Start Vite Frontend
|
||||
echo "Starting Vite React Frontend..."
|
||||
cd "$FRONTEND_DIR" || exit 1
|
||||
npm run dev -- --port 3000 &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
# Wait for background tasks
|
||||
wait
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Header from './components/Header';
|
||||
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import SellerList from './pages/sellers/SellerList';
|
||||
import SellerDetail from './pages/sellers/SellerDetail';
|
||||
import CustomerList from './pages/customers/CustomerList';
|
||||
import CustomerDetail from './pages/customers/CustomerDetail';
|
||||
import ProductList from './pages/products/ProductList';
|
||||
import CategoryList from './pages/products/CategoryList';
|
||||
import AuditLogs from './pages/AuditLogs';
|
||||
import OrderPaymentStatus from './pages/orders/OrderPaymentStatus';
|
||||
import IssueRefund from './pages/orders/IssueRefund';
|
||||
import CreateReturnPickup from './pages/orders/CreateReturnPickup';
|
||||
|
||||
const ProtectedLayout = ({ children }) => {
|
||||
const { token, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', color: '#fff' }}>Loading Admin Session...</div>;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<Sidebar />
|
||||
<main className="main-content">
|
||||
<Header />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppRoutes = () => {
|
||||
const { token } = useAuth();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={!token ? <Login /> : <Navigate to="/" replace />} />
|
||||
|
||||
<Route path="/" element={<ProtectedLayout><Dashboard /></ProtectedLayout>} />
|
||||
<Route path="/sellers" element={<ProtectedLayout><SellerList /></ProtectedLayout>} />
|
||||
<Route path="/sellers/:id" element={<ProtectedLayout><SellerDetail /></ProtectedLayout>} />
|
||||
<Route path="/customers" element={<ProtectedLayout><CustomerList /></ProtectedLayout>} />
|
||||
<Route path="/customers/:id" element={<ProtectedLayout><CustomerDetail /></ProtectedLayout>} />
|
||||
<Route path="/products" element={<ProtectedLayout><ProductList /></ProtectedLayout>} />
|
||||
<Route path="/categories" element={<ProtectedLayout><CategoryList /></ProtectedLayout>} />
|
||||
<Route path="/audit" element={<ProtectedLayout><AuditLogs /></ProtectedLayout>} />
|
||||
|
||||
<Route path="/orders/payment" element={<ProtectedLayout><OrderPaymentStatus /></ProtectedLayout>} />
|
||||
<Route path="/orders/refund" element={<ProtectedLayout><IssueRefund /></ProtectedLayout>} />
|
||||
<Route path="/orders/return" element={<ProtectedLayout><CreateReturnPickup /></ProtectedLayout>} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<AppRoutes />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1289
frontend/src/app.css
32
frontend/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Error {
|
||||
message: string;
|
||||
code?: string;
|
||||
module?: string;
|
||||
label?: string;
|
||||
}
|
||||
interface Locals {
|
||||
user?: any; // You might want to replace 'any' with a more specific type for user
|
||||
org?: any; // You might want to replace 'any' with a more specific type for org
|
||||
org_name?: string;
|
||||
org_settings?: {
|
||||
default_currency?: string;
|
||||
currency_symbol?: string;
|
||||
default_country?: string | null;
|
||||
timezone?: string;
|
||||
};
|
||||
profile?: {
|
||||
role?: string;
|
||||
is_organization_admin?: boolean;
|
||||
};
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
30
frontend/src/app.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Google Fonts - Geist (kept for back-compat), Inter (new primary), JetBrains Mono (kbd chips) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-JNWHD22PPN"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-JNWHD22PPN');
|
||||
</script>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 13 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
|
@ -1,128 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import { API_BASE_URL } from '../config';
|
||||
|
||||
const DocumentViewer = ({ documents }) => {
|
||||
const [previewDoc, setPreviewDoc] = useState(null);
|
||||
|
||||
const resolveUrl = (url) => {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('/')) {
|
||||
return `${API_BASE_URL}${url}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const handleDownload = (doc) => {
|
||||
// Downloads directly from S3 URL returned by backend in doc.url
|
||||
const link = document.createElement('a');
|
||||
link.href = resolveUrl(doc.url);
|
||||
link.download = doc.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card" style={{ marginTop: '24px' }}>
|
||||
<h3 className="section-title">KYC Document Verification</h3>
|
||||
{documents && documents.length > 0 ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
|
||||
{documents.map((doc, idx) => (
|
||||
<div key={idx} style={{
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<span style={{ fontWeight: '600' }}>{doc.type}</span>
|
||||
<StatusBadge status={doc.status} />
|
||||
</div>
|
||||
<div style={{
|
||||
height: '180px',
|
||||
background: 'var(--bg-primary)',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px dashed var(--glass-border)',
|
||||
marginBottom: '12px',
|
||||
fontSize: '13px',
|
||||
padding: '8px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{doc.filename.match(/\.(jpg|jpeg|png)$/i) ? (
|
||||
<img src={resolveUrl(doc.preview_url || doc.url)} alt={doc.type} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: '4px', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<span>[Document Preview: {doc.filename}]</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1, fontSize: '11px', padding: '6px 10px' }}
|
||||
onClick={() => setPreviewDoc(doc)}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, fontSize: '11px', padding: '6px 10px', background: 'rgba(255,255,255,0.05)', color: '#fff', border: '1px solid var(--glass-border)' }}
|
||||
onClick={() => handleDownload(doc)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: 'var(--text-secondary)' }}>No KYC documents submitted yet.</p>
|
||||
)}
|
||||
|
||||
{/* Lightbox / Preview Modal Overlay */}
|
||||
{previewDoc && (
|
||||
<div className="modal-overlay" onClick={() => setPreviewDoc(null)}>
|
||||
<div className="modal-content" style={{ maxWidth: '800px', width: '90%' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 style={{ fontSize: '20px', fontWeight: '600' }}>Document Preview: {previewDoc.type}</h2>
|
||||
<button className="modal-close" onClick={() => setPreviewDoc(null)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ display: 'flex', justifyContent: 'center', background: '#000', borderRadius: '8px', padding: '16px', overflow: 'hidden' }}>
|
||||
{previewDoc.filename.match(/\.(jpg|jpeg|png)$/i) ? (
|
||||
<img
|
||||
src={resolveUrl(previewDoc.preview_url || previewDoc.url)}
|
||||
alt={previewDoc.type}
|
||||
style={{ maxWidth: '100%', maxHeight: '60vh', objectFit: 'contain' }}
|
||||
/>
|
||||
) : previewDoc.filename.match(/\.pdf$/i) ? (
|
||||
<iframe
|
||||
src={`https://docs.google.com/gview?url=${encodeURIComponent(resolveUrl(previewDoc.preview_url || previewDoc.url))}&embedded=true`}
|
||||
title={previewDoc.type}
|
||||
style={{ width: '100%', height: '60vh', border: 'none', background: '#fff' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '40px', color: '#fff', textAlign: 'center' }}>
|
||||
<p style={{ marginBottom: '16px' }}>Preview is not supported directly for this file type ({previewDoc.filename}).</p>
|
||||
<button className="btn btn-primary" onClick={() => handleDownload(previewDoc)}>Download to View</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer" style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}>
|
||||
<button className="btn btn-secondary" onClick={() => setPreviewDoc(null)}>Close Preview</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentViewer;
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import React from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const Header = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingBottom: '24px',
|
||||
borderBottom: '1px solid var(--glass-border)',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '20px' }}>Admin Portal</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>Manage sellers, customers, logistics & payments</p>
|
||||
</div>
|
||||
{user && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontWeight: '600', fontSize: '14px' }}>{user.username}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--accent)', textTransform: 'uppercase', fontWeight: '700' }}>
|
||||
{user.role.replace('_', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
border: '1px solid var(--accent)'
|
||||
}}>
|
||||
👤
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const Sidebar = () => {
|
||||
const { logout } = useAuth();
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-logo">
|
||||
<span>🛡️ DigiHox CRM</span>
|
||||
</div>
|
||||
<nav style={{ flex: 1 }}>
|
||||
<ul className="sidebar-menu">
|
||||
<li className="sidebar-item">
|
||||
<NavLink to="/" end className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
📊 Dashboard
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="sidebar-item">
|
||||
<NavLink to="/sellers" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
👤 Sellers
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="sidebar-item">
|
||||
<NavLink to="/customers" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
🛒 Customers
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="sidebar-item">
|
||||
<NavLink to="/products" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
📦 Products
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="sidebar-item">
|
||||
<NavLink to="/categories" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
🏷️ Categories
|
||||
</NavLink>
|
||||
</li>
|
||||
<li className="sidebar-item">
|
||||
<NavLink to="/audit" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
📋 Audit Logs
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div style={{ marginTop: 'auto' }}>
|
||||
<button className="btn btn-secondary" style={{ width: '100%' }} onClick={logout}>
|
||||
🚪 Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
const StatusBadge = ({ status }) => {
|
||||
const getBadgeClass = (stat) => {
|
||||
switch (stat?.toLowerCase()) {
|
||||
case 'approved':
|
||||
case 'active':
|
||||
case 'completed':
|
||||
case 'success':
|
||||
case 'resolved':
|
||||
case 'delivered':
|
||||
return 'badge-success';
|
||||
case 'pending':
|
||||
case 'pending_approval':
|
||||
case 'initiated':
|
||||
case 'pickup_scheduled':
|
||||
return 'badge-warning';
|
||||
case 'rejected':
|
||||
case 'failed':
|
||||
case 'suspended':
|
||||
case 'banned':
|
||||
return 'badge-danger';
|
||||
default:
|
||||
return 'badge-info';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`badge ${getBadgeClass(status)}`}>
|
||||
{status?.replace('_', ' ')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBadge;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
// DigiHox CRM - Frontend Configuration
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "https://betaapicsa.tradhox.com/api";
|
||||
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { API_BASE_URL } from '../config';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(localStorage.getItem('crm_token') || null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
if (token) {
|
||||
if (token === 'mock_jwt_token_for_digihox_crm') {
|
||||
setUser({
|
||||
username: localStorage.getItem('crm_username') || 'vignesh',
|
||||
email: localStorage.getItem('crm_email') || 'vichunice@gmail.com',
|
||||
role: 'super_admin',
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/auth/me/`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser({
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
role: data.role
|
||||
});
|
||||
} else {
|
||||
logout();
|
||||
}
|
||||
} catch (_err) {
|
||||
// Fallback to local user session if server is offline
|
||||
setUser({
|
||||
username: localStorage.getItem('crm_username') || 'vignesh',
|
||||
email: localStorage.getItem('crm_email') || 'vichunice@gmail.com',
|
||||
role: 'super_admin',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [token]);
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/auth/login/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const accessToken = data.access;
|
||||
localStorage.setItem('crm_token', accessToken);
|
||||
setToken(accessToken);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("API login failed, attempting local fallback...", err);
|
||||
}
|
||||
|
||||
if ((username === 'admin' && password === 'admin') || (username === 'vignesh' && password === 'vtechnosoft@123A')) {
|
||||
const mockToken = 'mock_jwt_token_for_digihox_crm';
|
||||
localStorage.setItem('crm_token', mockToken);
|
||||
localStorage.setItem('crm_username', username);
|
||||
localStorage.setItem('crm_email', username === 'vignesh' ? 'vichunice@gmail.com' : 'admin@digihox.com');
|
||||
setToken(mockToken);
|
||||
setUser({
|
||||
username: username,
|
||||
email: username === 'vignesh' ? 'vichunice@gmail.com' : 'admin@digihox.com',
|
||||
role: 'super_admin',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
throw new Error('Invalid credentials');
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('crm_token');
|
||||
localStorage.removeItem('crm_username');
|
||||
localStorage.removeItem('crm_email');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, login, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
return useContext(AuthContext);
|
||||
};
|
||||
33
frontend/src/env.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { defineEnvVars } from '@sveltejs/kit/env';
|
||||
|
||||
/**
|
||||
* Every environment variable the app reads. SvelteKit 3 exposes only what is
|
||||
* declared here, through `$app/env/public` and `$app/env/private`.
|
||||
*
|
||||
* None is `static`, so each is read when the server starts rather than baked
|
||||
* in at build time: one Docker image serves any deployment. Each schema passes
|
||||
* the raw value through, so an unset variable is `undefined`, which is what
|
||||
* every caller already handles.
|
||||
*/
|
||||
/** @param {string | undefined} value */
|
||||
const optional = (value) => value;
|
||||
|
||||
export const variables = defineEnvVars({
|
||||
PUBLIC_DJANGO_API_URL: {
|
||||
public: true,
|
||||
schema: optional,
|
||||
description: 'The Django API origin, without a trailing /api.'
|
||||
},
|
||||
PUBLIC_SENTRY_DSN: {
|
||||
public: true,
|
||||
schema: optional,
|
||||
description: 'Sentry DSN. Unset disables Sentry.'
|
||||
},
|
||||
RELAY_SECRET: {
|
||||
schema: optional,
|
||||
description: "Shared with the API's RELAY_SECRET so it believes the relayed visitor address."
|
||||
},
|
||||
GOOGLE_CLIENT_ID: { schema: optional },
|
||||
GOOGLE_LOGIN_DOMAIN: { schema: optional },
|
||||
NODE_ENV: { schema: optional }
|
||||
});
|
||||
16
frontend/src/hooks.client.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { handleErrorWithSentry, replayIntegration } from '@sentry/sveltekit';
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
import * as env from '$app/env/public';
|
||||
|
||||
const dsn = env.PUBLIC_SENTRY_DSN || '';
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
enabled: !!dsn,
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [replayIntegration()]
|
||||
});
|
||||
|
||||
export const handleError = handleErrorWithSentry();
|
||||
396
frontend/src/hooks.server.js
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
/**
|
||||
* SvelteKit Server Hooks with JWT Authentication
|
||||
*
|
||||
* Authentication Flow:
|
||||
* 1. JWT tokens stored in cookies (httpOnly for security)
|
||||
* 2. Decode JWT locally to check org context (no API call needed)
|
||||
* 3. Only call switch-org if token doesn't have correct org
|
||||
* 4. Routes protected based on authentication and org membership
|
||||
*/
|
||||
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import axios from 'axios';
|
||||
import * as env from '$app/env/public';
|
||||
import { isOrgAdmin } from '#lib/admin.js';
|
||||
import { describeError } from '#lib/server/log-safe.js';
|
||||
import { moduleOffError } from '#lib/server/v2/modules.js';
|
||||
import { relayHeaders } from '#lib/server/relay.js';
|
||||
|
||||
const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`;
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* @typedef {{ default_currency?: string, currency_symbol?: string, default_country?: string|null, timezone?: string }} OrgSettingsPayload
|
||||
* @typedef {{ org_id?: string, org_name?: string, role?: string, is_organization_admin?: boolean, user_id?: string, user_name?: string, user_email?: string, user_profile_pic?: string, exp?: number, iat?: number, org_settings?: OrgSettingsPayload }} JWTPayload
|
||||
* @typedef {{ id: string, name: string }} OrgInfo
|
||||
* @typedef {{ org: OrgInfo, role?: string, is_organization_admin?: boolean }} ProfileInfo
|
||||
* @typedef {{ id?: string, organizations?: Array<{ id: string, name: string }> }} UserInfo
|
||||
* @typedef {{ access_token: string, refresh_token: string, current_org?: OrgInfo }} SwitchOrgResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode JWT payload without verification (for reading claims only)
|
||||
* @param {string} token - JWT token
|
||||
* @returns {JWTPayload|null} Decoded payload or null if invalid
|
||||
*/
|
||||
function decodeJwtPayload(token) {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const payload = parts[1];
|
||||
// Handle base64url encoding
|
||||
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = Buffer.from(base64, 'base64').toString('utf8');
|
||||
return /** @type {JWTPayload} */ (JSON.parse(jsonPayload));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if JWT token has the specified org_id in its payload
|
||||
* @param {string} token - JWT token
|
||||
* @param {string} orgId - Organization UUID to check
|
||||
* @returns {boolean} True if token has the org context
|
||||
*/
|
||||
function tokenHasOrgContext(token, orgId) {
|
||||
const payload = decodeJwtPayload(token);
|
||||
return Boolean(payload && payload.org_id === orgId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify JWT token locally by checking expiration
|
||||
* @param {string} accessToken - JWT access token
|
||||
* @returns {JWTPayload|null} JWT payload or null if expired/invalid
|
||||
*/
|
||||
function verifyTokenLocally(accessToken) {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
if (!payload) return null;
|
||||
|
||||
// Check if token is expired
|
||||
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes currently in flight, keyed by the refresh token being spent.
|
||||
* Entries are removed as soon as the request settles, so this holds at most one
|
||||
* entry per user mid-refresh.
|
||||
* @type {Map<string, Promise<{access: string, refresh?: string}|null>>}
|
||||
*/
|
||||
const refreshesInFlight = new Map();
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token.
|
||||
*
|
||||
* The backend rotates refresh tokens: the one we send is blacklisted server-side
|
||||
* and a replacement comes back in `refresh`. Callers MUST persist that
|
||||
* replacement, reusing the old token on the next refresh gets a 401 and logs
|
||||
* the user out.
|
||||
*
|
||||
* Concurrent requests arriving with the same expired access token would
|
||||
* otherwise each spend the same refresh token, and every one but the winner
|
||||
* would 401. Requests sharing a refresh token therefore share one round-trip.
|
||||
*
|
||||
* @param {string} refreshToken - JWT refresh token
|
||||
* @param {{ getClientAddress: () => string, request: Request }} event - For the visitor's address
|
||||
* @returns {Promise<{access: string, refresh?: string}|null>} New tokens or null if refresh failed
|
||||
*/
|
||||
function refreshAccessToken(refreshToken, event) {
|
||||
const existing = refreshesInFlight.get(refreshToken);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const pending = performTokenRefresh(refreshToken, event).finally(() => {
|
||||
refreshesInFlight.delete(refreshToken);
|
||||
});
|
||||
refreshesInFlight.set(refreshToken, pending);
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual refresh round-trip. Use refreshAccessToken() instead.
|
||||
* It deduplicates concurrent callers.
|
||||
*
|
||||
* The API writes an audit row for every refresh, so the visitor's signed
|
||||
* address goes along (`#lib/server/relay.js`); without it the row records
|
||||
* this server.
|
||||
*
|
||||
* @param {string} refreshToken - JWT refresh token
|
||||
* @param {{ getClientAddress: () => string, request: Request }} event - For the visitor's address
|
||||
* @returns {Promise<{access: string, refresh?: string}|null>} New tokens or null if refresh failed
|
||||
*/
|
||||
async function performTokenRefresh(refreshToken, event) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/auth/refresh-token/`,
|
||||
{ refresh: refreshToken },
|
||||
{ headers: relayHeaders(event) }
|
||||
);
|
||||
|
||||
if (!response.data?.access) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { access: response.data.access, refresh: response.data.refresh };
|
||||
} catch (error) {
|
||||
// Never log the raw error: its axios `config.data` is the refresh token.
|
||||
console.error('Token refresh failed:', describeError(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Profile info is now embedded in JWT - no API call needed
|
||||
|
||||
/**
|
||||
* Switch organization and get new tokens with org context.
|
||||
*
|
||||
* Passes the outgoing refresh token so the backend can blacklist it. Otherwise
|
||||
* it stays valid against the previous org for the rest of its lifetime even
|
||||
* though we replace it in the cookie below.
|
||||
*
|
||||
* @param {string} accessToken - Current JWT access token
|
||||
* @param {string} orgId - Organization UUID to switch to
|
||||
* @param {string | undefined} refreshToken - Refresh token being replaced, to be retired
|
||||
* @param {{ getClientAddress: () => string, request: Request }} event - For the visitor's address,
|
||||
* which the API's org-switch audit row records (`#lib/server/relay.js`)
|
||||
* @returns {Promise<SwitchOrgResult|null>} New tokens and org data or null if failed
|
||||
*/
|
||||
async function switchOrg(accessToken, orgId, refreshToken, event) {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/auth/switch-org/`,
|
||||
refreshToken ? { org_id: orgId, refresh: refreshToken } : { org_id: orgId },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...relayHeaders(event)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Never log the raw error: its axios `config.headers` carries the JWT.
|
||||
console.error('Org switch failed:', describeError(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler of our own replaces SvelteKit's default, so this keeps printing
|
||||
* what it printed for an unexpected error: the stack only, never the error object.
|
||||
*/
|
||||
const reportError = Sentry.handleErrorWithSentry(
|
||||
/** @param {{ kind?: string, error: unknown }} input */
|
||||
({ kind, error }) => {
|
||||
if (kind !== 'unknown') return;
|
||||
for (let e = error; e; e = e instanceof Error ? e.cause : undefined) {
|
||||
console.error(e instanceof Error ? e.stack : String(e));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A module the API calls switched off renders as the module page. It is an
|
||||
* answer, not a fault, so it is checked before Sentry captures anything.
|
||||
*
|
||||
* @param {Parameters<typeof reportError>[0]} input
|
||||
*/
|
||||
export const handleError = (input) => moduleOffError(input.error) ?? reportError(input);
|
||||
|
||||
export const handle = sequence(Sentry.sentryHandle(), async function _handle({ event, resolve }) {
|
||||
// Get tokens from cookies
|
||||
/** @type {string | undefined} */
|
||||
let accessToken = event.cookies.get('jwt_access');
|
||||
// Reassigned if we rotate below, so later calls hand on the live token rather
|
||||
// than the one we just spent.
|
||||
let refreshToken = event.cookies.get('jwt_refresh');
|
||||
const orgId = event.cookies.get('org');
|
||||
|
||||
/** @type {JWTPayload | null} */
|
||||
let jwtPayload = null;
|
||||
|
||||
// Try to authenticate user (LOCAL JWT DECODE - no API call!)
|
||||
if (accessToken) {
|
||||
jwtPayload = verifyTokenLocally(accessToken);
|
||||
|
||||
// If access token expired, try to refresh
|
||||
if (!jwtPayload && refreshToken) {
|
||||
const refreshed = await refreshAccessToken(refreshToken, event);
|
||||
if (refreshed) {
|
||||
// Update cookie with new access token
|
||||
event.cookies.set('jwt_access', refreshed.access, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 // 1 day
|
||||
});
|
||||
// Persist the rotated refresh token. The one we just spent is
|
||||
// blacklisted server-side, so keeping it would 401 the next refresh
|
||||
// and bounce the user to /login an hour later.
|
||||
if (refreshed.refresh) {
|
||||
event.cookies.set('jwt_refresh', refreshed.refresh, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 365 // 1 year
|
||||
});
|
||||
refreshToken = refreshed.refresh;
|
||||
}
|
||||
accessToken = refreshed.access;
|
||||
jwtPayload = verifyTokenLocally(refreshed.access);
|
||||
} else {
|
||||
// Refresh failed, clear cookies
|
||||
event.cookies.delete('jwt_access', { path: '/' });
|
||||
event.cookies.delete('jwt_refresh', { path: '/' });
|
||||
event.cookies.delete('org', { path: '/' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set user in locals from JWT payload (no API call needed!)
|
||||
if (jwtPayload) {
|
||||
// Build user object from JWT claims
|
||||
event.locals.user = {
|
||||
id: jwtPayload.user_id,
|
||||
name: jwtPayload.user_name || '',
|
||||
email: jwtPayload.user_email || '',
|
||||
profilePhoto: jwtPayload.user_profile_pic || ''
|
||||
};
|
||||
|
||||
// Check if org cookie is set and token has org context
|
||||
if (orgId && !UUID_RE.test(orgId)) {
|
||||
// Invalid org cookie value, clear it
|
||||
event.cookies.delete('org', { path: '/' });
|
||||
} else if (orgId) {
|
||||
const token = /** @type {string} */ (accessToken);
|
||||
|
||||
if (tokenHasOrgContext(token, orgId)) {
|
||||
// Token has org context - extract org info from JWT (no API call!)
|
||||
event.locals.org = {
|
||||
id: jwtPayload.org_id || orgId,
|
||||
name: jwtPayload.org_name || 'Organization'
|
||||
};
|
||||
/** @type {any} */ (event.locals).profile = {
|
||||
org: event.locals.org,
|
||||
role: jwtPayload.role || 'USER',
|
||||
// The admin fact every admin gate reads (`#lib/admin.js`). Server
|
||||
// derived and signed; `role` stays for display only.
|
||||
is_organization_admin: isOrgAdmin(jwtPayload)
|
||||
};
|
||||
event.locals.org_name = jwtPayload.org_name || 'Organization';
|
||||
// Extract org settings for currency/locale
|
||||
event.locals.org_settings = jwtPayload.org_settings || {
|
||||
default_currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
default_country: null
|
||||
};
|
||||
} else {
|
||||
// Token doesn't have org context, need to switch (1 API call)
|
||||
const switchResult = await switchOrg(token, orgId, refreshToken, event);
|
||||
|
||||
if (switchResult) {
|
||||
// Update cookies with new tokens that have org context
|
||||
event.cookies.set('jwt_access', switchResult.access_token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 // 1 day
|
||||
});
|
||||
event.cookies.set('jwt_refresh', switchResult.refresh_token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 365 // 1 year
|
||||
});
|
||||
|
||||
// Extract org info from switch result (no additional API call!)
|
||||
event.locals.org = switchResult.current_org;
|
||||
// The freshly-issued token carries this org's role and settings, so
|
||||
// read both from it. Assuming 'USER' here would drop an admin's
|
||||
// admin-only nav for the one navigation right after an org switch.
|
||||
const newPayload = decodeJwtPayload(switchResult.access_token);
|
||||
/** @type {any} */ (event.locals).profile = {
|
||||
org: switchResult.current_org,
|
||||
role: newPayload?.role || 'USER',
|
||||
is_organization_admin: isOrgAdmin(newPayload)
|
||||
};
|
||||
event.locals.org_name = switchResult.current_org?.name || 'Organization';
|
||||
event.locals.org_settings = newPayload?.org_settings || {
|
||||
default_currency: 'USD',
|
||||
currency_symbol: '$',
|
||||
default_country: null
|
||||
};
|
||||
} else {
|
||||
// Org switch failed, clear org cookie and redirect
|
||||
event.cookies.delete('org', { path: '/' });
|
||||
throw redirect(303, '/org');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route protection
|
||||
const pathname = event.url.pathname;
|
||||
|
||||
// Define public routes (no auth required).
|
||||
//
|
||||
// `/portal` and `/csat` are the customer-facing pages reached from an emailed
|
||||
// link, the invoice/estimate portals and the CSAT survey. They are anonymous
|
||||
// by design: the only credential is the token in the URL, and the pages read
|
||||
// nothing from the session or org (only the token-scoped public Django
|
||||
// endpoints). Without them here the guard redirects every customer who clicks
|
||||
// a link to /login, so the portal is unreachable. Server-side token→org
|
||||
// resolution + RLS is what actually protects the data (see docs/PORTAL_RLS.md).
|
||||
//
|
||||
// `/help-center` is an org's public help center: anonymous and indexable,
|
||||
// opted into per org. Its pages read only `/api/public/help/<slug>/`, which
|
||||
// answers the same 404 for an unknown, disabled or inactive org. It is NOT
|
||||
// `/help`: that prefix is the signed-in user's own support tickets
|
||||
// (`(app)/help`), and listing it here would strip the guard from them.
|
||||
const PUBLIC_ROUTES = ['/login', '/logout', '/bounce', '/portal', '/csat', '/help-center'];
|
||||
|
||||
// Define semi-protected routes (auth required, but no org)
|
||||
const AUTH_ONLY_ROUTES = ['/org'];
|
||||
|
||||
// Check if public route
|
||||
const isPublicRoute = PUBLIC_ROUTES.some(
|
||||
(route) => pathname === route || pathname.startsWith(route + '/')
|
||||
);
|
||||
|
||||
// Check if auth-only route
|
||||
const isAuthOnlyRoute = AUTH_ONLY_ROUTES.some(
|
||||
(route) => pathname === route || pathname.startsWith(route + '/')
|
||||
);
|
||||
|
||||
if (isAuthOnlyRoute) {
|
||||
// Auth-only route - require user
|
||||
if (!jwtPayload) {
|
||||
throw redirect(307, '/login');
|
||||
}
|
||||
} else if (!isPublicRoute) {
|
||||
// Protected route - require user + org
|
||||
if (!jwtPayload) {
|
||||
throw redirect(307, '/login');
|
||||
}
|
||||
if (!event.locals.org) {
|
||||
throw redirect(307, '/org');
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
});
|
||||
180
frontend/src/hooks.server.test.js
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* The shell's token refresh and org switch send the visitor's signed address
|
||||
* (`#lib/server/relay.js`): the API writes an audit row for each, and without
|
||||
* it the row records this server.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// What Sentry captured: its wrapper captures, then calls the app's handler.
|
||||
const captured = vi.hoisted(() => /** @type {unknown[]} */ ([]));
|
||||
vi.mock('@sentry/sveltekit', () => ({
|
||||
sentryHandle:
|
||||
() =>
|
||||
(/** @type {any} */ { event, resolve }) =>
|
||||
resolve(event),
|
||||
handleErrorWithSentry: (/** @type {any} */ handler) => (/** @type {any} */ input) => {
|
||||
captured.push(input.error);
|
||||
return handler(input);
|
||||
}
|
||||
}));
|
||||
// `sequence` needs SvelteKit's request store, which only a running server has.
|
||||
// Sentry's handle is a pass-through here, so the app's own handle is the last.
|
||||
vi.mock('@sveltejs/kit/hooks', () => ({
|
||||
sequence: (/** @type {any[]} */ ...handles) => handles[handles.length - 1]
|
||||
}));
|
||||
vi.mock('axios', () => ({ default: { post: vi.fn(), get: vi.fn() } }));
|
||||
|
||||
const axios = (await import('axios')).default;
|
||||
// The stub that vitest.config.js aliases `$app/env/private` to, so this is the module the code
|
||||
// under test reads. Imported by path because only the stub has a setter.
|
||||
const { setRelaySecret } = await import('../test/stubs/app-env-private.js');
|
||||
const { handle, handleError } = await import('./hooks.server.js');
|
||||
|
||||
const SECRET = 'r'.repeat(48);
|
||||
const VISITOR = '198.51.100.7';
|
||||
const BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/142.0 Safari/537.36';
|
||||
const SIGNED = {
|
||||
'X-Forwarded-For': VISITOR,
|
||||
'X-BottleCRM-Relay-Secret': SECRET,
|
||||
'X-BottleCRM-Client-IP': VISITOR,
|
||||
'X-BottleCRM-User-Agent': BROWSER
|
||||
};
|
||||
const ORG = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
/** @param {Record<string, unknown>} claims */
|
||||
const jwt = (claims) =>
|
||||
['h', Buffer.from(JSON.stringify(claims)).toString('base64url'), 's'].join('.');
|
||||
const hourFromNow = () => Math.floor(Date.now() / 1000) + 3600;
|
||||
|
||||
/** @param {Record<string, string>} jar */
|
||||
function event(jar) {
|
||||
return /** @type {any} */ ({
|
||||
url: new URL('http://app.test/login'),
|
||||
request: new Request('http://app.test/login', { headers: { 'user-agent': BROWSER } }),
|
||||
route: { id: '/(no-layout)/login' },
|
||||
locals: {},
|
||||
cookies: {
|
||||
get: (/** @type {string} */ name) => jar[name],
|
||||
set: vi.fn(),
|
||||
delete: vi.fn()
|
||||
},
|
||||
getClientAddress: () => VISITOR
|
||||
});
|
||||
}
|
||||
|
||||
const resolve = vi.fn(async () => new Response('ok'));
|
||||
|
||||
/** @param {string} path */
|
||||
const callTo = (path) =>
|
||||
vi.mocked(axios.post).mock.calls.find(([url]) => String(url).endsWith(path));
|
||||
|
||||
beforeEach(() => {
|
||||
setRelaySecret(SECRET);
|
||||
vi.mocked(axios.post).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setRelaySecret(undefined);
|
||||
});
|
||||
|
||||
describe('hooks.server.js relays the visitor', () => {
|
||||
it('on a token refresh', async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
data: { access: jwt({ user_id: 'u', exp: hourFromNow() }) }
|
||||
});
|
||||
|
||||
await handle({
|
||||
event: event({ jwt_access: jwt({ user_id: 'u', exp: 1 }), jwt_refresh: 'r-1' }),
|
||||
resolve
|
||||
});
|
||||
|
||||
const [, body, config] = /** @type {any[]} */ (callTo('/auth/refresh-token/'));
|
||||
expect(body).toEqual({ refresh: 'r-1' });
|
||||
expect(config.headers).toEqual(SIGNED);
|
||||
});
|
||||
|
||||
it('on an org switch', async () => {
|
||||
vi.mocked(axios.post).mockResolvedValue({
|
||||
data: {
|
||||
access_token: jwt({ user_id: 'u', org_id: ORG, exp: hourFromNow() }),
|
||||
refresh_token: 'r-2',
|
||||
current_org: { id: ORG, name: 'Acme' }
|
||||
}
|
||||
});
|
||||
|
||||
await handle({
|
||||
event: event({
|
||||
jwt_access: jwt({ user_id: 'u', exp: hourFromNow() }),
|
||||
jwt_refresh: 'r-1',
|
||||
org: ORG
|
||||
}),
|
||||
resolve
|
||||
});
|
||||
|
||||
const [, , config] = /** @type {any[]} */ (callTo('/auth/switch-org/'));
|
||||
expect(config.headers).toMatchObject(SIGNED);
|
||||
expect(config.headers.Authorization).toMatch(/^Bearer /);
|
||||
});
|
||||
|
||||
it('sends only the pre-1.12 X-Forwarded-For without a secret', async () => {
|
||||
setRelaySecret(undefined);
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: {} });
|
||||
|
||||
await handle({
|
||||
event: event({ jwt_access: jwt({ user_id: 'u', exp: 1 }), jwt_refresh: 'r-3' }),
|
||||
resolve
|
||||
});
|
||||
|
||||
expect(/** @type {any[]} */ (callTo('/auth/refresh-token/'))[2].headers).toEqual({
|
||||
'X-Forwarded-For': VISITOR
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hooks.server.js handleError', () => {
|
||||
beforeEach(() => {
|
||||
captured.length = 0;
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('keeps a switched-off module out of Sentry, and still reports any other error', () => {
|
||||
const detail = 'Tickets is switched off for this organization.';
|
||||
const moduleOff = Object.assign(new Error(detail), {
|
||||
status: 404,
|
||||
body: { detail, code: 'module_off', module: 'tickets' }
|
||||
});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
handleError(/** @type {any} */ ({ kind: 'unknown', error: moduleOff }));
|
||||
expect(captured).toEqual([]);
|
||||
const other = new Error('boom');
|
||||
handleError(/** @type {any} */ ({ kind: 'unknown', error: other }));
|
||||
expect(captured).toEqual([other]);
|
||||
});
|
||||
|
||||
it('shows a module the API calls switched off as the module page', () => {
|
||||
const detail = 'Tickets is switched off for this organization.';
|
||||
const error = Object.assign(new Error(detail), {
|
||||
status: 404,
|
||||
body: { detail, code: 'module_off', module: 'tickets' }
|
||||
});
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(handleError(/** @type {any} */ ({ kind: 'unknown', error }))).toEqual({
|
||||
status: 404,
|
||||
message: detail,
|
||||
code: 'module_off',
|
||||
module: 'tickets'
|
||||
});
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still prints the stack of any other unexpected error, and only the stack', () => {
|
||||
const error = Object.assign(new Error('boom'), { config: { headers: { Authorization: 'x' } } });
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(handleError(/** @type {any} */ ({ kind: 'unknown', error }))).toBeUndefined();
|
||||
expect(log).toHaveBeenCalledWith(error.stack);
|
||||
expect(
|
||||
handleError(/** @type {any} */ ({ kind: 'app', error: { status: 404 } }))
|
||||
).toBeUndefined();
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,422 +0,0 @@
|
|||
/* DigiHox CRM - Premium Glassmorphism Dark Theme Design System */
|
||||
|
||||
:root {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
|
||||
/* Color Palette (Deep dark theme with purple/violet accents) */
|
||||
--bg-primary: #0a0b10;
|
||||
--bg-secondary: #12131a;
|
||||
--bg-tertiary: #1b1d28;
|
||||
|
||||
/* Glassmorphism settings */
|
||||
--glass-bg: rgba(20, 22, 33, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.08);
|
||||
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||
|
||||
/* Text colors */
|
||||
--text-primary: #f3f4f6;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
|
||||
/* Accent Colors */
|
||||
--accent: #8b5cf6; /* Violet */
|
||||
--accent-hover: #a78bfa;
|
||||
--accent-light: rgba(139, 92, 246, 0.15);
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #10b981;
|
||||
--color-success-bg: rgba(16, 185, 129, 0.1);
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-bg: rgba(245, 158, 11, 0.1);
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--color-info: #3b82f6;
|
||||
--color-info-bg: rgba(59, 130, 246, 0.1);
|
||||
|
||||
--border-radius: 12px;
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Glassmorphism Card Container */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
padding: 24px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 8px 36px 0 rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Main Layout Grid */
|
||||
.app-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
background: radial-gradient(circle at top right, rgba(139, 92, 246, 0.05), transparent 60%);
|
||||
}
|
||||
|
||||
/* Typography classes */
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
background: linear-gradient(135deg, #fff 0%, var(--accent-hover) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Navigation Sidebar Styles */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-right: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-item a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.sidebar-item a:hover,
|
||||
.sidebar-item.active a {
|
||||
background-color: var(--accent-light);
|
||||
color: #fff;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
/* Tables styling */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.custom-table th {
|
||||
padding: 16px;
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.custom-table td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.custom-table tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* Badges styling */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.badge-success { background-color: var(--color-success-bg); color: var(--color-success); }
|
||||
.badge-warning { background-color: var(--color-warning-bg); color: var(--color-warning); }
|
||||
.badge-danger { background-color: var(--color-danger-bg); color: var(--color-danger); }
|
||||
.badge-info { background-color: var(--color-info-bg); color: var(--color-info); }
|
||||
|
||||
/* Forms & inputs */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--border-radius);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--border-radius);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
gap: 8px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
box-shadow: 0 0 16px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Stat Cards for Dashboard */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Modal Popup Styling */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-hover);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.details-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.details-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.details-value {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
19
frontend/src/instrumentation.server.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as Sentry from '@sentry/sveltekit';
|
||||
import * as env from '$app/env/public';
|
||||
|
||||
const dsn = env.PUBLIC_SENTRY_DSN || '';
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
enabled: !!dsn,
|
||||
tracesSampleRate: 1.0,
|
||||
// Sentry 11 collects all of these by default. Keep the server where v10 left
|
||||
// it: SSR form actions carry customer contact data and the request cookies
|
||||
// carry the session, so none of it goes to Sentry.
|
||||
dataCollection: {
|
||||
userInfo: false,
|
||||
cookies: false,
|
||||
httpBodies: [],
|
||||
stackFrameVariables: false
|
||||
}
|
||||
});
|
||||
23
frontend/src/lib/admin.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Whether the signed-in user administers the current org.
|
||||
*
|
||||
* The one place the web app decides this. It reads `is_organization_admin`, a
|
||||
* fact the API derives with `common.permissions.is_org_admin` (the ADMIN role,
|
||||
* or a Django superuser's membership) and signs into the access token. It
|
||||
* never compares `role`: a superuser who holds the USER role is an admin
|
||||
* everywhere on the server, and a `role === 'ADMIN'` test showed them
|
||||
* read-only pages the API would have let them change.
|
||||
*
|
||||
* A display hint only. It hides or shows controls; every write is refused or
|
||||
* allowed by the backend, which re-derives the fact on each request.
|
||||
*
|
||||
* Only a literal `true` counts, so a missing claim (a token minted before the
|
||||
* claim existed, which the hourly refresh replaces) reads as "not an admin".
|
||||
*
|
||||
* @param {Record<string, unknown> | null | undefined} source
|
||||
* JWT claims, `locals.profile`, or layout data: anything carrying the fact.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isOrgAdmin(source) {
|
||||
return source?.is_organization_admin === true;
|
||||
}
|
||||
30
frontend/src/lib/admin.test.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isOrgAdmin } from './admin.js';
|
||||
|
||||
describe('isOrgAdmin', () => {
|
||||
it('is true only when the server says so', () => {
|
||||
expect(isOrgAdmin({ is_organization_admin: true })).toBe(true);
|
||||
expect(isOrgAdmin({ is_organization_admin: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('admits a USER-role superuser, whose token carries the fact', () => {
|
||||
expect(isOrgAdmin({ role: 'USER', is_organization_admin: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('never reads the role', () => {
|
||||
expect(isOrgAdmin({ role: 'ADMIN' })).toBe(false);
|
||||
expect(isOrgAdmin({ role: 'ADMIN', is_organization_admin: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses anything that is not literally true', () => {
|
||||
for (const value of ['true', 1, 'yes', {}, null, undefined]) {
|
||||
expect(isOrgAdmin({ is_organization_admin: value })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a missing source', () => {
|
||||
expect(isOrgAdmin(null)).toBe(false);
|
||||
expect(isOrgAdmin(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
183
frontend/src/lib/api-helpers.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* API Helper Functions
|
||||
*
|
||||
* Shared utilities for making API requests to Django backend.
|
||||
* Used by SvelteKit server files during migration from Prisma to API.
|
||||
*/
|
||||
|
||||
import * as env from '$app/env/public';
|
||||
|
||||
const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`;
|
||||
|
||||
/**
|
||||
* @typedef {import('@sveltejs/kit').Cookies} Cookies
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make authenticated API request to Django backend
|
||||
*
|
||||
* This helper is designed for SvelteKit server-side use, where cookies
|
||||
* are managed by hooks and locals contains the user and org context.
|
||||
*
|
||||
* @param {string} endpoint - API endpoint (e.g., '/accounts/', '/leads/123/')
|
||||
* @param {{ method?: string, body?: Record<string, unknown> | FormData, headers?: Record<string, string>, quietErrors?: boolean, expectedStatuses?: number[] }} options - Fetch options.
|
||||
* `quietErrors` logs a failed request's status and the server's `message`
|
||||
* only, never the response body: for replies that can quote the caller's
|
||||
* data, such as an import's skipped rows and their cells.
|
||||
* `expectedStatuses` lists statuses that are an answer rather than a failure
|
||||
* (a 404 from a route this install does not have): they are thrown as usual,
|
||||
* with `status` and `body`, but never logged.
|
||||
* @param {{ cookies?: Cookies, org?: { id: string } } | Cookies} locals - SvelteKit locals object or cookies directly
|
||||
* @returns {Promise<any>} Response data
|
||||
* @throws {Error} If request fails
|
||||
*/
|
||||
export async function apiRequest(endpoint, options = {}, locals) {
|
||||
const {
|
||||
method = 'GET',
|
||||
body,
|
||||
headers = {},
|
||||
quietErrors = false,
|
||||
expectedStatuses = []
|
||||
} = options;
|
||||
|
||||
// Get access token from cookies (server-side)
|
||||
// In server load functions, cookies is passed directly
|
||||
/** @type {Cookies | undefined} */
|
||||
const cookies = /** @type {Cookies | undefined} */ (
|
||||
/** @type {{ cookies?: Cookies }} */ (locals).cookies || locals
|
||||
);
|
||||
const accessToken = cookies?.get?.('jwt_access');
|
||||
|
||||
// A FormData body is a file upload (multipart). Do NOT set Content-Type for it
|
||||
//. Fetch has to add the multipart boundary itself, and a hand-set
|
||||
// 'application/json' would make Django parse the parts as a JSON string and
|
||||
// find neither the field nor the file. Plain-object bodies stay JSON, so every
|
||||
// existing caller is unaffected.
|
||||
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
|
||||
// Build request headers
|
||||
/** @type {Record<string, string>} */
|
||||
const requestHeaders = {
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...headers
|
||||
};
|
||||
|
||||
// Add authentication
|
||||
// Note: Organization context is now embedded in JWT token, not sent as header
|
||||
if (accessToken) {
|
||||
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
// Build request options
|
||||
/** @type {RequestInit} */
|
||||
const requestOptions = {
|
||||
method,
|
||||
headers: requestHeaders
|
||||
};
|
||||
|
||||
// Add body for non-GET requests
|
||||
if (body && method !== 'GET') {
|
||||
requestOptions.body = isFormData ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
// Handle error responses
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
// Extract meaningful error message from various Django error formats
|
||||
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
|
||||
if (errorData.detail) {
|
||||
errorMessage = errorData.detail;
|
||||
} else if (errorData.error && typeof errorData.error === 'string') {
|
||||
errorMessage = errorData.error;
|
||||
} else if (errorData.errors && typeof errorData.errors === 'object') {
|
||||
// Handle field-level errors
|
||||
const fieldErrors = Object.entries(errorData.errors)
|
||||
.map(([field, msgs]) => `${field}: ${Array.isArray(msgs) ? msgs.join(', ') : msgs}`)
|
||||
.join('; ');
|
||||
errorMessage = fieldErrors || errorMessage;
|
||||
} else if (errorData.non_field_errors) {
|
||||
errorMessage = Array.isArray(errorData.non_field_errors)
|
||||
? errorData.non_field_errors.join(', ')
|
||||
: errorData.non_field_errors;
|
||||
} else if (typeof errorData === 'object' && Object.keys(errorData).length > 0) {
|
||||
// Handle field-level validation errors from DRF serializers
|
||||
const fieldErrors = Object.entries(errorData)
|
||||
.filter(([key, value]) => key !== 'error' || typeof value === 'string')
|
||||
.map(([field, msgs]) => {
|
||||
if (Array.isArray(msgs)) {
|
||||
return `${field}: ${msgs.join(', ')}`;
|
||||
} else if (typeof msgs === 'string') {
|
||||
return `${field}: ${msgs}`;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
if (fieldErrors) {
|
||||
errorMessage = fieldErrors;
|
||||
}
|
||||
}
|
||||
|
||||
// A switched-off module's refusal is an answer: the page that asked
|
||||
// becomes the module page, so it is not logged as a failure.
|
||||
if (expectedStatuses.includes(response.status) || errorData?.code === 'module_off') {
|
||||
// Thrown below, not logged.
|
||||
} else if (quietErrors) {
|
||||
console.error(
|
||||
`API Error Response: ${method} ${endpoint} ${response.status}`,
|
||||
typeof errorData.message === 'string' ? errorData.message : ''
|
||||
);
|
||||
} else {
|
||||
console.error(`API Error Response:`, errorData);
|
||||
}
|
||||
/*
|
||||
* The status travels with the error.
|
||||
*
|
||||
* Callers used to decide "is this a 404?" by searching the message for
|
||||
* the string "404" or "not found", and Django's own message for a
|
||||
* missing record is "No Lead matches the given query.", which contains
|
||||
* neither. So a perfectly ordinary 404 rendered as a 500. Sniffing prose
|
||||
* to recover a number that was right there is a bug waiting for the day
|
||||
* somebody rewords an error.
|
||||
*/
|
||||
const failure = /** @type {Error & { status: number, body: any }} */ (
|
||||
new Error(errorMessage)
|
||||
);
|
||||
failure.status = response.status;
|
||||
failure.body = errorData;
|
||||
throw failure;
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return JSON response
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
const { status, body: failedBody } = /** @type {{ status?: number, body?: any }} */ (
|
||||
error ?? {}
|
||||
);
|
||||
if (
|
||||
(status !== undefined && expectedStatuses.includes(status)) ||
|
||||
failedBody?.code === 'module_off'
|
||||
) {
|
||||
// An expected answer, already left unlogged above.
|
||||
} else if (!quietErrors) {
|
||||
console.error(`API request failed: ${method} ${endpoint}`, error);
|
||||
} else if (!(/** @type {{ status?: number }} */ (error).status)) {
|
||||
// A network failure; an error reply was logged quietly above. The
|
||||
// error object itself is not logged, since it carries the body.
|
||||
console.error(`API request failed: ${method} ${endpoint}`, String(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
98
frontend/src/lib/api-helpers.test.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('$app/env/public', () => ({ PUBLIC_DJANGO_API_URL: 'http://api.test' }));
|
||||
|
||||
const { apiRequest } = await import('#lib/api-helpers.js');
|
||||
const cookies = /** @type {any} */ ({ get: () => 'token' });
|
||||
|
||||
/** @param {number} status @param {any} body */
|
||||
function answer(status, body) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status,
|
||||
statusText: 'x',
|
||||
json: async () => body
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe('apiRequest expectedStatuses', () => {
|
||||
/** @type {import('vitest').MockInstance} */
|
||||
let logged;
|
||||
beforeEach(() => {
|
||||
logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
logged.mockRestore();
|
||||
});
|
||||
|
||||
it('throws an expected status with its status and body, and logs nothing', async () => {
|
||||
answer(404, { detail: 'Not found.' });
|
||||
await expect(
|
||||
apiRequest('/objects/', { quietErrors: true, expectedStatuses: [404] }, { cookies })
|
||||
).rejects.toMatchObject({ status: 404, body: { detail: 'Not found.' } });
|
||||
expect(logged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs nothing for an expected status without quietErrors either', async () => {
|
||||
answer(404, {});
|
||||
await expect(
|
||||
apiRequest('/objects/', { expectedStatuses: [404] }, { cookies })
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
expect(logged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still logs a status that is not expected', async () => {
|
||||
answer(500, { detail: 'boom' });
|
||||
await expect(
|
||||
apiRequest('/objects/', { quietErrors: true, expectedStatuses: [404] }, { cookies })
|
||||
).rejects.toMatchObject({ status: 500 });
|
||||
expect(logged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is unchanged without the option', async () => {
|
||||
answer(404, {});
|
||||
await expect(apiRequest('/objects/', { quietErrors: true }, { cookies })).rejects.toMatchObject(
|
||||
{ status: 404 }
|
||||
);
|
||||
expect(logged).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiRequest and a switched-off module', () => {
|
||||
/** @type {import('vitest').MockInstance} */
|
||||
let logged;
|
||||
beforeEach(() => {
|
||||
logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
logged.mockRestore();
|
||||
});
|
||||
|
||||
const refusal = {
|
||||
detail: 'Tickets is switched off for this organization.',
|
||||
code: 'module_off',
|
||||
module: 'tickets'
|
||||
};
|
||||
|
||||
it('throws a module_off refusal with its body and logs nothing', async () => {
|
||||
for (const options of [{}, { quietErrors: true }]) {
|
||||
answer(404, refusal);
|
||||
await expect(apiRequest('/cases/', options, { cookies })).rejects.toMatchObject({
|
||||
status: 404,
|
||||
body: refusal
|
||||
});
|
||||
}
|
||||
expect(logged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still logs any other 404', async () => {
|
||||
answer(404, { detail: 'Not found.', code: 'not_found' });
|
||||
await expect(apiRequest('/cases/x/', {}, { cookies })).rejects.toMatchObject({ status: 404 });
|
||||
expect(logged).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
BIN
frontend/src/lib/assets/images/banner.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
9
frontend/src/lib/assets/images/google.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" className="LgbsSe-Bz112c">
|
||||
<g>
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"></path>
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"></path>
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"></path>
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"></path>
|
||||
<path fill="none" d="M0 0h48v48H0z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/src/lib/assets/images/img_login.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/src/lib/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
112
frontend/src/lib/components/ErrorPage.svelte
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<script>
|
||||
import { resolve } from '$app/paths';
|
||||
import { asInternalPath } from '#lib/utils/paths.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import { ArrowLeft, Home, Compass, Users, Building2, BarChart3 } from '@lucide/svelte';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {number} [status] HTTP status code
|
||||
* @property {string} [message] Error message from the server (optional)
|
||||
* @property {boolean} [showQuickLinks] Show the "Try one of these" panel (default true for 404)
|
||||
*/
|
||||
|
||||
/** @type {Props} */
|
||||
let { status = 404, message = '', showQuickLinks } = $props();
|
||||
|
||||
let isNotFound = $derived(status === 404);
|
||||
let showLinks = $derived(showQuickLinks ?? isNotFound);
|
||||
|
||||
let title = $derived.by(() => {
|
||||
if (isNotFound) return 'Page not found';
|
||||
if (status === 403) return "You don't have access to this page";
|
||||
if (status === 401) return 'Please sign in to continue';
|
||||
if (status >= 500) return 'Something went wrong';
|
||||
return 'Unable to open this page';
|
||||
});
|
||||
|
||||
let description = $derived.by(() => {
|
||||
if (message) return message;
|
||||
if (isNotFound) return "The page you're looking for doesn't exist or may have been moved.";
|
||||
if (status === 403) return 'Your account does not have permission to view this resource.';
|
||||
if (status === 401) return 'Your session may have expired. Sign in again to continue.';
|
||||
if (status >= 500)
|
||||
return "An unexpected error occurred on our end. We've been notified. Please try again in a moment.";
|
||||
return 'Please check the URL or try going back to where you came from.';
|
||||
});
|
||||
|
||||
const quickLinks = [
|
||||
{ href: '/', label: 'Dashboard', icon: BarChart3 },
|
||||
{ href: '/accounts', label: 'Accounts', icon: Building2 },
|
||||
{ href: '/contacts', label: 'Contacts', icon: Users }
|
||||
];
|
||||
|
||||
function goBack() {
|
||||
if (typeof history !== 'undefined' && history.length > 1) history.back();
|
||||
else goto(resolve(''));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-[80vh] w-full items-center justify-center px-6 py-16">
|
||||
<div class="w-full max-w-xl text-center">
|
||||
<!-- Stylized status code -->
|
||||
<div class="relative mb-6 flex items-center justify-center">
|
||||
<div
|
||||
class="bg-primary/10 absolute inset-0 mx-auto h-32 w-32 rounded-full blur-3xl"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="relative">
|
||||
<span
|
||||
class="text-primary block text-[7rem] leading-none font-bold tracking-tighter md:text-[9rem]"
|
||||
style="font-feature-settings: 'tnum';"
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Headline + supporting copy -->
|
||||
<h1 class="text-foreground mb-3 text-2xl font-semibold tracking-tight md:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mx-auto mb-8 max-w-md text-base leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<!-- Primary actions -->
|
||||
<div class="flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||
<Button onclick={() => goto(resolve(''))} class="gap-2">
|
||||
<Home class="h-4 w-4" />
|
||||
Go to dashboard
|
||||
</Button>
|
||||
<Button variant="outline" onclick={goBack} class="gap-2">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Quick links (404 only by default) -->
|
||||
{#if showLinks}
|
||||
<div class="mt-12">
|
||||
<div class="mb-4 flex items-center justify-center gap-2">
|
||||
<Compass class="text-muted-foreground h-4 w-4" />
|
||||
<span class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Try one of these
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
{#each quickLinks as link (link.href)}
|
||||
<a
|
||||
href={resolve(asInternalPath(link.href))}
|
||||
class="border-border/60 bg-background hover:border-primary/40 hover:bg-accent hover:text-foreground text-muted-foreground inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
>
|
||||
<link.icon class="h-3.5 w-3.5" />
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
import { refreshAll } from '$app/navigation';
|
||||
import { Upload } from '@lucide/svelte';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import * as Dialog from '#lib/components/ui/dialog/index.js';
|
||||
import ImportFlow from '#lib/components/import/ImportFlow.svelte';
|
||||
|
||||
/**
|
||||
* The contacts list's import dialog: `ImportFlow` in a dialog, refreshing
|
||||
* the list after an import.
|
||||
*
|
||||
* @type {{ open: boolean, onOpenChange?: (v: boolean) => void }}
|
||||
*/
|
||||
let { open = $bindable(false), onOpenChange } = $props();
|
||||
|
||||
let done = $state(false);
|
||||
/** Bumped on each opening, so every opening starts again at choosing a file. */
|
||||
let session = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
untrack(() => {
|
||||
session += 1;
|
||||
done = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(v) => onOpenChange?.(v)}>
|
||||
<Dialog.Content class="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Upload class="h-4 w-4" />
|
||||
Import contacts
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Bring in a spreadsheet of contacts. You'll check how its columns match, and nothing is
|
||||
written until you choose Import.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#key session}
|
||||
<ImportFlow
|
||||
entity="contacts"
|
||||
onDone={async () => {
|
||||
done = true;
|
||||
await refreshAll();
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button class="max-md:h-11" type="button" variant="outline" onclick={() => (open = false)}>
|
||||
{done ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
651
frontend/src/lib/components/import/ImportFlow.svelte
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import { deserialize } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { AlertCircle, CheckCircle2, Download, FileSpreadsheet, Upload } from '@lucide/svelte';
|
||||
import { IMPORT_ENTITIES } from '#lib/import/entities.js';
|
||||
import {
|
||||
droppedLines,
|
||||
hasAnyMatch,
|
||||
mappingPayload,
|
||||
setColumnField,
|
||||
skippedRowsCsv,
|
||||
suggestLeads,
|
||||
summaryLine
|
||||
} from '#lib/import/mapping.js';
|
||||
import { downloadCsv } from '#lib/utils/csv.js';
|
||||
import { asInternalPath } from '#lib/utils/paths.js';
|
||||
|
||||
/**
|
||||
* Import a spreadsheet of contacts or leads: choose a file, check how its
|
||||
* columns match the fields, import.
|
||||
*
|
||||
* The first preview lets the server guess the columns (`mapping=auto`).
|
||||
* Changing a column's field re-runs the preview with the full choice, 400 ms
|
||||
* after the last change, since preview and commit share a per-user throttle.
|
||||
* The import sends the same choice the preview showed. Both go through the
|
||||
* list page's form actions (`IMPORT_ENTITIES[entity].actionPath`), so the
|
||||
* flow works from any page.
|
||||
*
|
||||
* `singular` and `plural` override the entity's default words (the leads
|
||||
* drawer passes the org's terminology). `onDone(created)` runs after a
|
||||
* successful import. `onUseLeads`, when given, turns the "import as leads"
|
||||
* suggestion into a button; it must switch `entity` to `leads`, and the
|
||||
* flow then previews the same file again. Without it the suggestion is text.
|
||||
*
|
||||
* @type {{
|
||||
* entity: 'contacts' | 'leads',
|
||||
* singular?: string,
|
||||
* plural?: string,
|
||||
* onDone?: (created: number) => void,
|
||||
* onUseLeads?: () => void
|
||||
* }}
|
||||
*/
|
||||
let { entity, singular, plural, onDone, onUseLeads } = $props();
|
||||
|
||||
/** @typedef {import('#lib/import/mapping.js').Column} Column */
|
||||
/** @typedef {import('#lib/import/mapping.js').Skipped} Skipped */
|
||||
/**
|
||||
* @typedef {{
|
||||
* columns: (Column & { header: string, samples: string[] })[],
|
||||
* fields: { name: string, label: string, required: boolean }[],
|
||||
* skipped: Skipped[],
|
||||
* dropped: { field: string, count: number }[],
|
||||
* summary: { total: number, valid: number, skipped: number }
|
||||
* }} Preview
|
||||
*/
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
const SKIPPED_SHOWN = 50;
|
||||
const TOO_LARGE = 'This file is too large to upload. Split it into smaller files.';
|
||||
const OFFLINE = 'Could not reach BottleCRM. Check your connection and try again.';
|
||||
|
||||
let cfg = $derived(IMPORT_ENTITIES[entity]);
|
||||
let nouns = $derived({ singular: singular ?? cfg.singular, plural: plural ?? cfg.plural });
|
||||
|
||||
let step = $state(/** @type {'select' | 'match' | 'done'} */ ('select'));
|
||||
/** @type {File | null} */
|
||||
let file = $state(null);
|
||||
/** @type {HTMLInputElement | null} */
|
||||
let fileInput = $state(null);
|
||||
let dragOver = $state(false);
|
||||
let loading = $state(false);
|
||||
let committing = $state(false);
|
||||
/** @type {string | null} */
|
||||
let error = $state(null);
|
||||
/** @type {Preview | null} */
|
||||
let preview = $state(null);
|
||||
/** @type {Preview['columns']} */
|
||||
let columns = $state([]);
|
||||
/** The `mapping` the shown preview was computed for. */
|
||||
let previewed = $state('');
|
||||
let showEmpty = $state(false);
|
||||
/** @type {{ created: number, skipped?: Skipped[], dropped?: { field: string, count: number }[] } | null} */
|
||||
let result = $state(null);
|
||||
|
||||
/** Only the latest preview request may change the screen. */
|
||||
let seq = 0;
|
||||
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
||||
let timer;
|
||||
let destroyed = false;
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
clearTimeout(timer);
|
||||
});
|
||||
|
||||
let stale = $derived(step === 'match' && mappingPayload(columns) !== previewed);
|
||||
let canImport = $derived(
|
||||
!!preview && !stale && !loading && !committing && preview.summary.valid > 0
|
||||
);
|
||||
// A column with no value in any row cannot import anything. Exports such as
|
||||
// Outlook's carry dozens of them, so they wait behind a toggle.
|
||||
let shown = $derived(showEmpty ? columns : columns.filter((c) => c.field || c.samples.length));
|
||||
let hiddenCount = $derived(columns.length - shown.length);
|
||||
|
||||
/**
|
||||
* @param {'importPreview' | 'importCommit'} action
|
||||
* @param {string} mapping
|
||||
*/
|
||||
async function post(action, mapping) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', /** @type {File} */ (file));
|
||||
fd.append('mapping', mapping);
|
||||
const res = await fetch(`${resolve(asInternalPath(cfg.actionPath))}?/${action}`, {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
});
|
||||
// The Node adapter refuses a body over BODY_SIZE_LIMIT (512K by default;
|
||||
// the image sets 6M) with a plain-text
|
||||
// 413 before the action runs, which `deserialize` cannot parse.
|
||||
if (res.status === 413) return { type: 'failure', data: { importError: TOO_LARGE } };
|
||||
return /** @type {any} */ (deserialize(await res.text()));
|
||||
}
|
||||
|
||||
/** @param {any} out @param {string} fallback */
|
||||
function failureText(out, fallback) {
|
||||
return out?.type === 'failure' && out.data?.importError
|
||||
? String(out.data.importError)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** @param {string} mapping `auto` for the server's guess, else `mappingPayload` */
|
||||
async function runPreview(mapping) {
|
||||
if (!file) return;
|
||||
const mine = ++seq;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const out = await post('importPreview', mapping);
|
||||
if (mine !== seq || destroyed) return;
|
||||
const body = out.type === 'success' ? out.data?.importPreview : null;
|
||||
if (body?.columns) {
|
||||
preview = body;
|
||||
if (mapping === 'auto') columns = body.columns;
|
||||
previewed = mapping === 'auto' ? mappingPayload(body.columns) : mapping;
|
||||
step = 'match';
|
||||
} else {
|
||||
error = failureText(out, 'The preview did not go through. Try again.');
|
||||
}
|
||||
} catch {
|
||||
if (mine === seq && !destroyed) error = OFFLINE;
|
||||
} finally {
|
||||
if (mine === seq) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {File | null | undefined} f */
|
||||
function choose(f) {
|
||||
if (!f) return;
|
||||
clearTimeout(timer);
|
||||
file = f;
|
||||
preview = null;
|
||||
columns = [];
|
||||
previewed = '';
|
||||
showEmpty = false;
|
||||
step = 'select';
|
||||
runPreview('auto');
|
||||
}
|
||||
|
||||
function startOver() {
|
||||
clearTimeout(timer);
|
||||
seq++;
|
||||
file = null;
|
||||
preview = null;
|
||||
columns = [];
|
||||
previewed = '';
|
||||
error = null;
|
||||
loading = false;
|
||||
step = 'select';
|
||||
}
|
||||
|
||||
/** @param {number} index @param {string} value '' for Don't import */
|
||||
function change(index, value) {
|
||||
columns = setColumnField(columns, index, value || null);
|
||||
// A new choice replaces whatever the last failed request said, as on the
|
||||
// phone. Reverting to the previewed choice sends nothing, so an error
|
||||
// left here would outlive its cause.
|
||||
error = null;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const mapping = mappingPayload(columns);
|
||||
// A request still in flight was for another choice; this one replaces it.
|
||||
if (mapping !== previewed || loading) runPreview(mapping);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function useLeads() {
|
||||
onUseLeads?.();
|
||||
choose(file);
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (!canImport || !preview) return;
|
||||
committing = true;
|
||||
error = null;
|
||||
try {
|
||||
const out = await post('importCommit', mappingPayload(columns));
|
||||
if (out.type === 'success' && out.data?.importCommit) {
|
||||
const done = out.data.importCommit;
|
||||
if (!destroyed) {
|
||||
// No pending or in-flight preview may pull the flow back from done.
|
||||
clearTimeout(timer);
|
||||
seq++;
|
||||
result = done;
|
||||
step = 'done';
|
||||
}
|
||||
// Even when the flow was closed mid-import: the records exist now, so
|
||||
// the caller still refreshes.
|
||||
onDone?.(done.created);
|
||||
return;
|
||||
}
|
||||
if (destroyed) return;
|
||||
const skipped = out.type === 'failure' ? out.data?.importSkipped : null;
|
||||
if (Array.isArray(skipped) && skipped.length) {
|
||||
// Nothing was importable after all, e.g. the records arrived since the
|
||||
// preview. Show the import's own account; the summary line says why.
|
||||
preview = {
|
||||
...preview,
|
||||
skipped,
|
||||
dropped: [],
|
||||
summary: { total: skipped.length, valid: 0, skipped: skipped.length }
|
||||
};
|
||||
} else {
|
||||
error = failureText(out, 'The import did not go through. Try again.');
|
||||
}
|
||||
} catch {
|
||||
if (!destroyed) error = OFFLINE;
|
||||
} finally {
|
||||
committing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
const csv = [
|
||||
cfg.templateHeaders.join(','),
|
||||
cfg.templateExample.map((v) => (v.includes(',') ? `"${v}"` : v)).join(',')
|
||||
].join('\n');
|
||||
downloadCsv(csv, `${entity}-import-template.csv`);
|
||||
}
|
||||
|
||||
/** @param {Skipped[]} skipped */
|
||||
function downloadSkipped(skipped) {
|
||||
// A byte-order mark, as the server's exports carry, so Excel reads
|
||||
// accented names as UTF-8. The importer reads the file with or without it.
|
||||
const csv = skippedRowsCsv(
|
||||
columns.map((c) => c.header),
|
||||
skipped
|
||||
);
|
||||
downloadCsv(`\uFEFF${csv}`, `${entity}-skipped-rows.csv`);
|
||||
}
|
||||
|
||||
/** @param {number} n */
|
||||
function rows(n) {
|
||||
return `${n} ${n === 1 ? 'row' : 'rows'}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="v2-root flow">
|
||||
{#if step === 'select'}
|
||||
<button
|
||||
type="button"
|
||||
class="drop"
|
||||
class:over={dragOver}
|
||||
disabled={loading}
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
dragOver = true;
|
||||
}}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
ondrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
choose(e.dataTransfer?.files?.[0]);
|
||||
}}
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
{#if loading && file}
|
||||
<span class="v2-spin"></span>
|
||||
<span class="drop-text"><b>Reading {file.name}</b></span>
|
||||
{:else}
|
||||
<Upload />
|
||||
<span class="drop-text">
|
||||
<b>Choose a spreadsheet</b>
|
||||
<span>or drop it here. .xlsx or .csv, up to 5 MB.</span>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".csv,.xlsx"
|
||||
hidden
|
||||
onchange={(e) => {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
// Cleared so picking the same file again still fires a change.
|
||||
e.currentTarget.value = '';
|
||||
choose(f);
|
||||
}}
|
||||
/>
|
||||
<p class="v2-hint">
|
||||
Any column names work. You'll check how they match before anything is imported.
|
||||
</p>
|
||||
{#if error}
|
||||
<p class="v2-error" role="alert"><AlertCircle size={14} />{error}</p>
|
||||
{/if}
|
||||
<p class="v2-hint">
|
||||
Starting from scratch?
|
||||
<button type="button" class="link" onclick={downloadTemplate}>Download a template</button>
|
||||
</p>
|
||||
{:else if step === 'match' && preview && file}
|
||||
<div class="file">
|
||||
<FileSpreadsheet />
|
||||
<span class="file-name">{file.name}</span>
|
||||
<button type="button" class="v2-btn v2-btn-quiet" disabled={committing} onclick={startOver}>
|
||||
Choose another file
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !hasAnyMatch(columns)}
|
||||
<p class="note" role="status">
|
||||
We couldn't match any columns. Make sure the first row of your sheet holds the column names.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="v2-table-wrap cols">
|
||||
<table class="v2-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Column in your file</th>
|
||||
<th>Sample values</th>
|
||||
<th>Import as</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each shown as c (c.index)}
|
||||
{@const name = c.header || `Column ${c.index + 1}`}
|
||||
<tr>
|
||||
<td class="col-name">{name}</td>
|
||||
<td class="col-samples">
|
||||
{#if c.samples.length}{c.samples.join(' · ')}{:else}<span class="v2-muted"
|
||||
>No values</span
|
||||
>{/if}
|
||||
</td>
|
||||
<td class="col-pick">
|
||||
<select
|
||||
class="v2-input"
|
||||
disabled={committing}
|
||||
aria-label="Import {name} as"
|
||||
value={c.field ?? ''}
|
||||
onchange={(e) => change(c.index, e.currentTarget.value)}
|
||||
>
|
||||
<option value="">Don't import</option>
|
||||
{#each preview.fields as f (f.name)}
|
||||
<option value={f.name}>{f.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if hiddenCount > 0 || showEmpty}
|
||||
<button type="button" class="link" onclick={() => (showEmpty = !showEmpty)}>
|
||||
{showEmpty
|
||||
? 'Hide the empty columns'
|
||||
: `Show ${hiddenCount} empty column${hiddenCount === 1 ? '' : 's'}`}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="report">
|
||||
<!-- After a failed preview nothing is updating until "Try again". -->
|
||||
{#if loading || (stale && !error)}
|
||||
<p class="v2-hint busy"><span class="v2-spin"></span>Updating the preview</p>
|
||||
{/if}
|
||||
{#each droppedLines(preview.dropped, preview.fields) as line (line)}
|
||||
<p class="v2-hint">{line}</p>
|
||||
{/each}
|
||||
{#if preview.skipped.length}
|
||||
<ul class="skipped">
|
||||
{#each preview.skipped.slice(0, SKIPPED_SHOWN) as s (s.row)}
|
||||
<li><span class="v2-num">Row {s.row}</span> {s.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if preview.skipped.length > SKIPPED_SHOWN}
|
||||
<p class="v2-hint">
|
||||
{rows(preview.skipped.length - SKIPPED_SHOWN)} more are in the download.
|
||||
</p>
|
||||
{/if}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="v2-btn"
|
||||
onclick={() => downloadSkipped(/** @type {Preview} */ (preview).skipped)}
|
||||
>
|
||||
<Download />Download skipped rows
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if suggestLeads(entity, preview.skipped)}
|
||||
<p class="note">
|
||||
Some rows were skipped for a missing first or last name. A lead needs only one of the two,
|
||||
so this file could go in as leads instead.
|
||||
{#if onUseLeads}
|
||||
<button type="button" class="v2-btn" disabled={committing} onclick={useLeads}>
|
||||
Import as leads instead
|
||||
</button>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="summary" aria-live="polite">{summaryLine(nouns, preview)}</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="v2-error" role="alert">
|
||||
<AlertCircle size={14} />{error}
|
||||
{#if stale && !loading}
|
||||
<button type="button" class="link" onclick={() => runPreview(mappingPayload(columns))}
|
||||
>Try again</button
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="v2-btn v2-btn-primary" disabled={!canImport} onclick={commit}>
|
||||
{#if committing}<span class="v2-spin"></span>{/if}
|
||||
Import {preview.summary.valid}
|
||||
{preview.summary.valid === 1 ? nouns.singular : nouns.plural}
|
||||
</button>
|
||||
</div>
|
||||
{:else if step === 'done' && result}
|
||||
<p class="v2-auth-note v2-auth-note-ok" role="status">
|
||||
<CheckCircle2 />
|
||||
<span>
|
||||
Imported <b>{result.created}</b>
|
||||
{result.created === 1 ? nouns.singular : nouns.plural}.
|
||||
</span>
|
||||
</p>
|
||||
{#each droppedLines(result.dropped ?? [], preview?.fields ?? []) as line (line)}
|
||||
<p class="v2-hint">{line}</p>
|
||||
{/each}
|
||||
{#if result.skipped?.length}
|
||||
{@const skipped = result.skipped}
|
||||
<p class="v2-hint">{rows(skipped.length)} skipped.</p>
|
||||
<div>
|
||||
<button type="button" class="v2-btn" onclick={() => downloadSkipped(skipped)}>
|
||||
<Download />Download skipped rows
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* `.v2-root` brings the v2 tokens into a dialog, which portals outside the
|
||||
app shell; the dialog keeps its own background. */
|
||||
.flow {
|
||||
background: transparent;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.flow p {
|
||||
margin: 0;
|
||||
}
|
||||
.drop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
padding: 18px;
|
||||
border: 1.5px dashed var(--v2-line);
|
||||
border-radius: var(--v2-radius);
|
||||
background: var(--v2-line-soft);
|
||||
color: var(--v2-ink);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.drop:hover,
|
||||
.drop.over {
|
||||
border-color: var(--v2-slate);
|
||||
background: var(--v2-card);
|
||||
}
|
||||
.drop:disabled {
|
||||
cursor: progress;
|
||||
}
|
||||
.drop :global(svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
color: var(--v2-slate);
|
||||
}
|
||||
.drop-text {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.drop-text span {
|
||||
font-size: 12.5px;
|
||||
color: var(--v2-slate);
|
||||
}
|
||||
.link {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: var(--v2-ink);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
justify-self: start;
|
||||
}
|
||||
.file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.file :global(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--v2-slate);
|
||||
}
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.note {
|
||||
padding: 10px 12px;
|
||||
border-radius: 9px;
|
||||
background: var(--v2-ember-soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.note .v2-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.cols {
|
||||
margin: 0;
|
||||
}
|
||||
.cols td.col-name,
|
||||
.cols td.col-samples {
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.cols td.col-name {
|
||||
font-weight: 600;
|
||||
width: 30%;
|
||||
}
|
||||
.cols td.col-samples {
|
||||
color: var(--v2-slate);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.cols td.col-pick {
|
||||
width: 32%;
|
||||
}
|
||||
.cols select {
|
||||
min-width: 170px;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
.report {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.busy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.busy .v2-spin {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.skipped {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
list-style: none;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--v2-line);
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.skipped li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.skipped .v2-num {
|
||||
color: var(--v2-slate);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.summary {
|
||||
font-weight: 600;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.flow .v2-error {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.flow .v2-btn,
|
||||
.cols select {
|
||||
min-height: 44px;
|
||||
}
|
||||
.link {
|
||||
min-height: 44px;
|
||||
}
|
||||
.actions .v2-btn-primary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
/* `.v2-table` rows are already cards here; the column name keeps the
|
||||
whole first line (no status tag to make room for) and the field picker
|
||||
takes a line of its own. */
|
||||
.cols td.col-name {
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.cols td.col-samples {
|
||||
padding: 0;
|
||||
}
|
||||
.cols td.col-pick {
|
||||
display: block;
|
||||
width: auto;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.cols select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
64
frontend/src/lib/components/leads/LeadImportDrawer.svelte
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
import { refreshAll } from '$app/navigation';
|
||||
import { Upload } from '@lucide/svelte';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import * as Dialog from '#lib/components/ui/dialog/index.js';
|
||||
import ImportFlow from '#lib/components/import/ImportFlow.svelte';
|
||||
|
||||
/**
|
||||
* The leads list's import dialog: `ImportFlow` in a dialog, refreshing the
|
||||
* list after an import. `singular` and `plural` are the org's own words for
|
||||
* a lead, so a pack that renames the module is not contradicted by this
|
||||
* dialog.
|
||||
*
|
||||
* @type {{ open: boolean, onOpenChange?: (v: boolean) => void, singular?: string, plural?: string }}
|
||||
*/
|
||||
let { open = $bindable(false), onOpenChange, singular = 'lead', plural = 'leads' } = $props();
|
||||
|
||||
let done = $state(false);
|
||||
/** Bumped on each opening, so every opening starts again at choosing a file. */
|
||||
let session = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
untrack(() => {
|
||||
session += 1;
|
||||
done = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(v) => onOpenChange?.(v)}>
|
||||
<Dialog.Content class="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Upload class="h-4 w-4" />
|
||||
Import {plural}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Bring in a spreadsheet of {plural}. You'll check how its columns match, and nothing is
|
||||
written until you choose Import.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#key session}
|
||||
<ImportFlow
|
||||
entity="leads"
|
||||
{singular}
|
||||
{plural}
|
||||
onDone={async () => {
|
||||
done = true;
|
||||
await refreshAll();
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button class="max-md:h-11" type="button" variant="outline" onclick={() => (open = false)}>
|
||||
{done ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
453
frontend/src/lib/components/tickets/TicketImportDrawer.svelte
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
<script>
|
||||
import { refreshAll } from '$app/navigation';
|
||||
import { deserialize } from '$app/forms';
|
||||
import { Loader2, Upload, FileText, Download, CheckCircle2, AlertCircle } from '@lucide/svelte';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import * as Dialog from '#lib/components/ui/dialog/index.js';
|
||||
import { downloadImportErrors } from '#lib/utils/csv.js';
|
||||
|
||||
/**
|
||||
* Two-step CSV import flow: upload → preview → confirm.
|
||||
* The drawer is purely presentational; the network calls go to the
|
||||
* SvelteKit form actions defined in tickets/+page.server.js, which
|
||||
* forward the multipart upload to Django.
|
||||
*
|
||||
* @type {{ open: boolean, onOpenChange?: (v: boolean) => void }}
|
||||
*/
|
||||
let { open = $bindable(false), onOpenChange } = $props();
|
||||
|
||||
/** @type {'select' | 'preview' | 'done'} */
|
||||
let step = $state('select');
|
||||
/** @type {File | null} */
|
||||
let file = $state(null);
|
||||
let dragOver = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
/** @type {{ summary: { total: number, valid: number, invalid: number }, valid: any[], errors: { row: number, field: string, message: string }[], header_error: string | null } | null} */
|
||||
let preview = $state(null);
|
||||
/** @type {string | null} */
|
||||
let formError = $state(null);
|
||||
/**
|
||||
* Per-row errors returned by the commit endpoint when the uploaded file
|
||||
* changed between preview and commit. Rendered in the same shape as preview
|
||||
* errors so users can act on them without re-running preview.
|
||||
* @type {{ row: number, field: string, message: string }[]}
|
||||
*/
|
||||
let commitErrors = $state([]);
|
||||
|
||||
/** @type {{ created: number, ids: string[] } | null} */
|
||||
let commitResult = $state(null);
|
||||
|
||||
const TEMPLATE_HEADERS = [
|
||||
'name',
|
||||
'status',
|
||||
'priority',
|
||||
'description',
|
||||
'case_type',
|
||||
'account_name',
|
||||
'contact_emails',
|
||||
'assigned_emails',
|
||||
'team_names',
|
||||
'tags',
|
||||
'closed_on'
|
||||
];
|
||||
const TEMPLATE_EXAMPLE = [
|
||||
'Login button broken',
|
||||
'New',
|
||||
'High',
|
||||
'Returns 500 on submit',
|
||||
'Incident',
|
||||
'Acme Corp',
|
||||
'pat@acme.test',
|
||||
'agent@yourco.com',
|
||||
'Support',
|
||||
'auth;urgent',
|
||||
''
|
||||
];
|
||||
|
||||
function reset() {
|
||||
step = 'select';
|
||||
file = null;
|
||||
preview = null;
|
||||
commitResult = null;
|
||||
commitErrors = [];
|
||||
formError = null;
|
||||
busy = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) reset();
|
||||
});
|
||||
|
||||
function downloadTemplate() {
|
||||
const csv = [
|
||||
TEMPLATE_HEADERS.join(','),
|
||||
TEMPLATE_EXAMPLE.map((v) => (v.includes(',') ? `"${v}"` : v)).join(',')
|
||||
].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'tickets-import-template.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** @param {Event & { currentTarget: HTMLInputElement }} e */
|
||||
function onFileChange(e) {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f) file = f;
|
||||
}
|
||||
|
||||
/** @param {DragEvent} e */
|
||||
function onDrop(e) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const f = e.dataTransfer?.files?.[0];
|
||||
if (f) file = f;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Node adapter refuses a body over BODY_SIZE_LIMIT (512K unless the
|
||||
* deploy sets it; the image sets 6M) with a plain-text 413 before the
|
||||
* action runs, which `deserialize` cannot parse.
|
||||
* @param {Response} res
|
||||
*/
|
||||
async function readResult(res) {
|
||||
if (res.status === 413) {
|
||||
return {
|
||||
type: 'failure',
|
||||
data: { importError: 'This file is too large to upload. Split it into smaller files.' }
|
||||
};
|
||||
}
|
||||
return /** @type {any} */ (deserialize(await res.text()));
|
||||
}
|
||||
|
||||
async function submitPreview() {
|
||||
if (!file) return;
|
||||
formError = null;
|
||||
commitErrors = [];
|
||||
busy = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('?/importPreview', { method: 'POST', body: fd });
|
||||
const result = await readResult(res);
|
||||
if (result.type === 'failure') {
|
||||
formError = String(result.data?.importError || 'Preview failed');
|
||||
return;
|
||||
}
|
||||
if (result.type === 'success' && result.data?.importPreview) {
|
||||
preview = result.data.importPreview;
|
||||
if (preview?.header_error) {
|
||||
formError = preview.header_error;
|
||||
return;
|
||||
}
|
||||
step = 'preview';
|
||||
} else {
|
||||
formError = 'Unexpected response from server';
|
||||
}
|
||||
} catch (err) {
|
||||
formError = err instanceof Error ? err.message : 'Preview failed';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCommit() {
|
||||
if (!file || !preview || preview.errors.length > 0) return;
|
||||
commitErrors = [];
|
||||
busy = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('?/importCommit', { method: 'POST', body: fd });
|
||||
const result = await readResult(res);
|
||||
if (result.type === 'failure') {
|
||||
formError = String(result.data?.importError || 'Import failed');
|
||||
const errs = result.data?.importErrors;
|
||||
if (Array.isArray(errs) && errs.length) {
|
||||
commitErrors = errs;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.type === 'success' && result.data?.importCommit) {
|
||||
commitResult = result.data.importCommit;
|
||||
step = 'done';
|
||||
await refreshAll();
|
||||
}
|
||||
} catch (err) {
|
||||
formError = err instanceof Error ? err.message : 'Import failed';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(v) => onOpenChange?.(v)}>
|
||||
<Dialog.Content class="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Upload class="h-4 w-4" />
|
||||
Import tickets from CSV
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Upload a CSV of tickets. We'll validate every row before writing anything. References
|
||||
(account, contacts, assignees) must already exist in your org.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if step === 'select'}
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full rounded-lg border-2 border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] p-6 text-left transition-colors hover:bg-[var(--surface-default)] {dragOver
|
||||
? 'border-[var(--color-primary-default)] bg-[var(--surface-default)]'
|
||||
: ''}"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
dragOver = true;
|
||||
}}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
ondrop={onDrop}
|
||||
onclick={() => document.getElementById('ticket-import-file')?.click()}
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<FileText class="h-8 w-8 shrink-0 text-[var(--text-secondary)]" />
|
||||
<div class="min-w-0">
|
||||
{#if file}
|
||||
<p class="truncate text-sm font-medium">{file.name}</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{(file.size / 1024).toFixed(1)} KB · click to replace or drop another file
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm font-medium">Drop a CSV file here, or click to choose</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">.csv only, up to 5 MB</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
id="ticket-import-file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
class="hidden"
|
||||
onchange={onFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-[var(--border-default)] bg-[var(--surface-muted)] p-3 text-xs"
|
||||
>
|
||||
<p class="font-medium">CSV format</p>
|
||||
<p class="mt-1 text-[var(--text-secondary)]">
|
||||
Required headers: <code class="rounded bg-[var(--surface-default)] px-1">name</code>,
|
||||
<code class="rounded bg-[var(--surface-default)] px-1">status</code>,
|
||||
<code class="rounded bg-[var(--surface-default)] px-1">priority</code>. Optional:
|
||||
description, case_type, account_name, contact_emails, assigned_emails, team_names, tags
|
||||
(semicolon-separated), closed_on (YYYY-MM-DD).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 inline-flex items-center gap-1 text-[var(--color-primary-default)] hover:underline max-md:min-h-11"
|
||||
onclick={downloadTemplate}
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />Download CSV template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if formError}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800"
|
||||
>
|
||||
<AlertCircle class="h-4 w-4 shrink-0" />
|
||||
<span>{formError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button class="max-md:h-11" type="button" variant="outline" onclick={() => (open = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button class="max-md:h-11" type="button" disabled={!file || busy} onclick={submitPreview}>
|
||||
{#if busy}<Loader2 class="mr-1 h-3.5 w-3.5 animate-spin" />{/if}
|
||||
Preview
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else if step === 'preview' && preview}
|
||||
{@const hasErrors = preview.errors.length > 0}
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-[var(--surface-muted)] px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
Total: {preview.summary.total}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-green-50 px-3 py-1 text-xs font-medium text-green-800"
|
||||
>
|
||||
<CheckCircle2 class="h-3.5 w-3.5" />Valid: {preview.summary.valid}
|
||||
</span>
|
||||
{#if preview.summary.invalid > 0}
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-red-50 px-3 py-1 text-xs font-medium text-red-800"
|
||||
>
|
||||
<AlertCircle class="h-3.5 w-3.5" />Invalid: {preview.summary.invalid}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if preview.valid.length > 0}
|
||||
<div class="max-h-48 overflow-auto rounded-md border border-[var(--border-default)]">
|
||||
<table class="w-full text-xs">
|
||||
<thead class="sticky top-0 bg-[var(--surface-muted)]">
|
||||
<tr>
|
||||
<th class="px-2 py-1 text-left font-medium">#</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Name</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Status</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Priority</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Account</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each preview.valid.slice(0, 20) as row (row.row)}
|
||||
<tr class="border-t border-[var(--border-default)]">
|
||||
<td class="px-2 py-1 text-[var(--text-secondary)]">{row.row}</td>
|
||||
<td class="max-w-[200px] truncate px-2 py-1">{row.name}</td>
|
||||
<td class="px-2 py-1">{row.status}</td>
|
||||
<td class="px-2 py-1">{row.priority}</td>
|
||||
<td class="max-w-[140px] truncate px-2 py-1 text-[var(--text-secondary)]">
|
||||
{row.account_id ? '✓' : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if preview.valid.length > 20}
|
||||
<div
|
||||
class="border-t border-[var(--border-default)] bg-[var(--surface-muted)] px-2 py-1 text-center text-[10px] text-[var(--text-secondary)]"
|
||||
>
|
||||
Showing first 20 of {preview.valid.length} valid rows
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasErrors}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-red-800">
|
||||
{preview.errors.length} error{preview.errors.length === 1 ? '' : 's'}, fix the CSV
|
||||
before importing
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-xs text-[var(--color-primary-default)] hover:underline max-md:min-h-11"
|
||||
onclick={() => downloadImportErrors(preview?.errors ?? [], 'tickets')}
|
||||
>
|
||||
<Download class="h-3 w-3" />Download errors
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-auto rounded-md border border-red-200 bg-red-50">
|
||||
<table class="w-full text-xs">
|
||||
<thead class="sticky top-0 bg-red-100">
|
||||
<tr>
|
||||
<th class="px-2 py-1 text-left font-medium">Row</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Field</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each preview.errors as err (`${err.row}-${err.field}-${err.message}`)}
|
||||
<tr class="border-t border-red-200">
|
||||
<td class="px-2 py-1 font-mono">{err.row}</td>
|
||||
<td class="px-2 py-1">{err.field}</td>
|
||||
<td class="px-2 py-1">{err.message}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if commitErrors.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-red-800">
|
||||
Server rejected {commitErrors.length} row{commitErrors.length === 1 ? '' : 's'} during
|
||||
import. The file may have changed since preview
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-xs text-[var(--color-primary-default)] hover:underline max-md:min-h-11"
|
||||
onclick={() => downloadImportErrors(commitErrors, 'tickets')}
|
||||
>
|
||||
<Download class="h-3 w-3" />Download errors
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-auto rounded-md border border-red-200 bg-red-50">
|
||||
<table class="w-full text-xs">
|
||||
<thead class="sticky top-0 bg-red-100">
|
||||
<tr>
|
||||
<th class="px-2 py-1 text-left font-medium">Row</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Field</th>
|
||||
<th class="px-2 py-1 text-left font-medium">Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each commitErrors as err (`${err.row}-${err.field}-${err.message}`)}
|
||||
<tr class="border-t border-red-200">
|
||||
<td class="px-2 py-1 font-mono">{err.row}</td>
|
||||
<td class="px-2 py-1">{err.field}</td>
|
||||
<td class="px-2 py-1">{err.message}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if formError}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800"
|
||||
>
|
||||
<AlertCircle class="h-4 w-4 shrink-0" />
|
||||
<span>{formError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button class="max-md:h-11" type="button" variant="outline" onclick={() => reset()}
|
||||
>Back</Button
|
||||
>
|
||||
<Button
|
||||
class="max-md:h-11"
|
||||
type="button"
|
||||
disabled={hasErrors || preview.summary.valid === 0 || busy}
|
||||
onclick={submitCommit}
|
||||
>
|
||||
{#if busy}<Loader2 class="mr-1 h-3.5 w-3.5 animate-spin" />{/if}
|
||||
Import {preview.summary.valid} ticket{preview.summary.valid === 1 ? '' : 's'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else if step === 'done' && commitResult}
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<CheckCircle2 class="h-6 w-6 text-green-700" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-900">
|
||||
Imported {commitResult.created} ticket{commitResult.created === 1 ? '' : 's'}
|
||||
</p>
|
||||
<p class="text-xs text-green-800">The tickets list has been refreshed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button class="max-md:h-11" type="button" onclick={() => (open = false)}>Close</Button>
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
115
frontend/src/lib/components/ui/button/button.svelte
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<script module>
|
||||
import { cn } from '#lib/utils.js';
|
||||
import { tv } from 'tailwind-variants';
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: [
|
||||
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap',
|
||||
'rounded-[var(--r-md)] text-[13px] font-medium leading-none tracking-[-0.01em]',
|
||||
'outline-none transition-[background-color,color,border-color,box-shadow] duration-150',
|
||||
'disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50',
|
||||
"[&_svg:not([class*='size-'])]:size-[14px] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:stroke-[1.7]",
|
||||
'focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]',
|
||||
'aria-invalid:border-[color:var(--red)] aria-invalid:shadow-[0_0_0_3px_var(--red-soft)]'
|
||||
].join(' '),
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
'bg-[color:var(--cta-bg)] text-[color:var(--cta-text)] border border-transparent',
|
||||
'hover:bg-[color:var(--cta-bg-hover,var(--cta-bg))]'
|
||||
].join(' '),
|
||||
ghost: [
|
||||
'bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]',
|
||||
'border border-[color:var(--border-faint)]',
|
||||
'hover:border-[color:var(--border)] hover:text-[color:var(--text)]'
|
||||
].join(' '),
|
||||
outline: [
|
||||
'bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]',
|
||||
'border border-[color:var(--border-faint)]',
|
||||
'hover:border-[color:var(--border)] hover:text-[color:var(--text)]'
|
||||
].join(' '),
|
||||
secondary: [
|
||||
'bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]',
|
||||
'border border-[color:var(--border-faint)]',
|
||||
'hover:border-[color:var(--border)] hover:text-[color:var(--text)]'
|
||||
].join(' '),
|
||||
destructive: [
|
||||
'bg-[color:var(--bg-elevated)] text-[color:var(--red)]',
|
||||
'border border-[color:var(--border-faint)]',
|
||||
'hover:border-[color:var(--red)]'
|
||||
].join(' '),
|
||||
link: [
|
||||
'bg-transparent text-[color:var(--text)] border border-transparent px-0',
|
||||
'underline-offset-4 hover:underline'
|
||||
].join(' ')
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 px-3 has-[>svg]:px-2.5',
|
||||
sm: 'h-7 gap-1.5 px-2.5 has-[>svg]:px-2 text-[12px]',
|
||||
lg: 'h-9 px-4 has-[>svg]:px-3.5',
|
||||
icon: 'size-8',
|
||||
'icon-sm': 'size-7',
|
||||
'icon-lg': 'size-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { resolve } from '$app/paths';
|
||||
import { asInternalPath } from '#lib/utils/paths.js';
|
||||
/**
|
||||
* @type {{
|
||||
* class?: string,
|
||||
* variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link',
|
||||
* size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg',
|
||||
* ref?: HTMLElement | null,
|
||||
* href?: string,
|
||||
* type?: 'button' | 'submit' | 'reset',
|
||||
* disabled?: boolean,
|
||||
* children?: import('svelte').Snippet,
|
||||
* [key: string]: any
|
||||
* }}
|
||||
*/
|
||||
let {
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = 'button',
|
||||
disabled = false,
|
||||
children,
|
||||
...restProps
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : resolve(asInternalPath(href))}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? 'link' : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
9
frontend/src/lib/components/ui/button/index.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import Root, { buttonVariants } from './button.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<script>
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||
38
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<script>
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import DialogPortal from './dialog-portal.svelte';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import * as Dialog from './index.js';
|
||||
import { cn } from '#lib/utils.js';
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className = undefined,
|
||||
portalProps = undefined,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DialogPortal {...portalProps}>
|
||||
<Dialog.Overlay class="" />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed start-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close
|
||||
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<script>
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '#lib/utils.js';
|
||||
|
||||
let { ref = $bindable(null), class: className = undefined, ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
13
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script>
|
||||
import { cn } from '#lib/utils.js';
|
||||
let { ref = $bindable(null), class: className = undefined, children, ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
14
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<script>
|
||||
import { cn } from '#lib/utils.js';
|
||||
|
||||
let { ref = $bindable(null), class: className = undefined, children, ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn('flex flex-col gap-2 text-center sm:text-start', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
16
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script>
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '#lib/utils.js';
|
||||
|
||||
let { ref = $bindable(null), class: className, ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<script>
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...restProps} />
|
||||
13
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script>
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '#lib/utils.js';
|
||||
|
||||
let { ref = $bindable(null), class: className = undefined, ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="dialog-title"
|
||||
class={cn('text-lg leading-none font-semibold', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<script>
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script>
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { open = $bindable(false), ...restProps } = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Root bind:open {...restProps} />
|
||||
34
frontend/src/lib/components/ui/dialog/index.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import Root from './dialog.svelte';
|
||||
import Portal from './dialog-portal.svelte';
|
||||
import Title from './dialog-title.svelte';
|
||||
import Footer from './dialog-footer.svelte';
|
||||
import Header from './dialog-header.svelte';
|
||||
import Overlay from './dialog-overlay.svelte';
|
||||
import Content from './dialog-content.svelte';
|
||||
import Description from './dialog-description.svelte';
|
||||
import Trigger from './dialog-trigger.svelte';
|
||||
import Close from './dialog-close.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Dialog,
|
||||
Title as DialogTitle,
|
||||
Portal as DialogPortal,
|
||||
Footer as DialogFooter,
|
||||
Header as DialogHeader,
|
||||
Trigger as DialogTrigger,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose
|
||||
};
|
||||
263
frontend/src/lib/constants/countries.js
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* Country codes and names for dropdown selection
|
||||
* Matches Django backend COUNTRIES in common/utils.py
|
||||
*/
|
||||
export const COUNTRIES = [
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'AF', name: 'Afghanistan' },
|
||||
{ code: 'AX', name: 'Aland Islands' },
|
||||
{ code: 'AL', name: 'Albania' },
|
||||
{ code: 'DZ', name: 'Algeria' },
|
||||
{ code: 'AS', name: 'American Samoa' },
|
||||
{ code: 'AD', name: 'Andorra' },
|
||||
{ code: 'AO', name: 'Angola' },
|
||||
{ code: 'AI', name: 'Anguilla' },
|
||||
{ code: 'AQ', name: 'Antarctica' },
|
||||
{ code: 'AG', name: 'Antigua and Barbuda' },
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{ code: 'AM', name: 'Armenia' },
|
||||
{ code: 'AW', name: 'Aruba' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'AZ', name: 'Azerbaijan' },
|
||||
{ code: 'BS', name: 'Bahamas' },
|
||||
{ code: 'BH', name: 'Bahrain' },
|
||||
{ code: 'BD', name: 'Bangladesh' },
|
||||
{ code: 'BB', name: 'Barbados' },
|
||||
{ code: 'BY', name: 'Belarus' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'BZ', name: 'Belize' },
|
||||
{ code: 'BJ', name: 'Benin' },
|
||||
{ code: 'BM', name: 'Bermuda' },
|
||||
{ code: 'BT', name: 'Bhutan' },
|
||||
{ code: 'BO', name: 'Bolivia' },
|
||||
{ code: 'BA', name: 'Bosnia and Herzegovina' },
|
||||
{ code: 'BW', name: 'Botswana' },
|
||||
{ code: 'BV', name: 'Bouvet Island' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'IO', name: 'British Indian Ocean Territory' },
|
||||
{ code: 'BN', name: 'Brunei Darussalam' },
|
||||
{ code: 'BG', name: 'Bulgaria' },
|
||||
{ code: 'BF', name: 'Burkina Faso' },
|
||||
{ code: 'BI', name: 'Burundi' },
|
||||
{ code: 'KH', name: 'Cambodia' },
|
||||
{ code: 'CM', name: 'Cameroon' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'CV', name: 'Cape Verde' },
|
||||
{ code: 'KY', name: 'Cayman Islands' },
|
||||
{ code: 'CF', name: 'Central African Republic' },
|
||||
{ code: 'TD', name: 'Chad' },
|
||||
{ code: 'CL', name: 'Chile' },
|
||||
{ code: 'CN', name: 'China' },
|
||||
{ code: 'CX', name: 'Christmas Island' },
|
||||
{ code: 'CC', name: 'Cocos (Keeling) Islands' },
|
||||
{ code: 'CO', name: 'Colombia' },
|
||||
{ code: 'KM', name: 'Comoros' },
|
||||
{ code: 'CG', name: 'Congo' },
|
||||
{ code: 'CD', name: 'Congo, The Democratic Republic of the' },
|
||||
{ code: 'CK', name: 'Cook Islands' },
|
||||
{ code: 'CR', name: 'Costa Rica' },
|
||||
{ code: 'CI', name: "Cote d'Ivoire" },
|
||||
{ code: 'HR', name: 'Croatia' },
|
||||
{ code: 'CU', name: 'Cuba' },
|
||||
{ code: 'CY', name: 'Cyprus' },
|
||||
{ code: 'CZ', name: 'Czech Republic' },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'DJ', name: 'Djibouti' },
|
||||
{ code: 'DM', name: 'Dominica' },
|
||||
{ code: 'DO', name: 'Dominican Republic' },
|
||||
{ code: 'EC', name: 'Ecuador' },
|
||||
{ code: 'EG', name: 'Egypt' },
|
||||
{ code: 'SV', name: 'El Salvador' },
|
||||
{ code: 'GQ', name: 'Equatorial Guinea' },
|
||||
{ code: 'ER', name: 'Eritrea' },
|
||||
{ code: 'EE', name: 'Estonia' },
|
||||
{ code: 'ET', name: 'Ethiopia' },
|
||||
{ code: 'FK', name: 'Falkland Islands (Malvinas)' },
|
||||
{ code: 'FO', name: 'Faroe Islands' },
|
||||
{ code: 'FJ', name: 'Fiji' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'GF', name: 'French Guiana' },
|
||||
{ code: 'PF', name: 'French Polynesia' },
|
||||
{ code: 'TF', name: 'French Southern Territories' },
|
||||
{ code: 'GA', name: 'Gabon' },
|
||||
{ code: 'GM', name: 'Gambia' },
|
||||
{ code: 'GE', name: 'Georgia' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'GH', name: 'Ghana' },
|
||||
{ code: 'GI', name: 'Gibraltar' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'GL', name: 'Greenland' },
|
||||
{ code: 'GD', name: 'Grenada' },
|
||||
{ code: 'GP', name: 'Guadeloupe' },
|
||||
{ code: 'GU', name: 'Guam' },
|
||||
{ code: 'GT', name: 'Guatemala' },
|
||||
{ code: 'GG', name: 'Guernsey' },
|
||||
{ code: 'GN', name: 'Guinea' },
|
||||
{ code: 'GW', name: 'Guinea-Bissau' },
|
||||
{ code: 'GY', name: 'Guyana' },
|
||||
{ code: 'HT', name: 'Haiti' },
|
||||
{ code: 'HM', name: 'Heard Island and McDonald Islands' },
|
||||
{ code: 'VA', name: 'Holy See (Vatican City State)' },
|
||||
{ code: 'HN', name: 'Honduras' },
|
||||
{ code: 'HK', name: 'Hong Kong' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'IS', name: 'Iceland' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'ID', name: 'Indonesia' },
|
||||
{ code: 'IR', name: 'Iran, Islamic Republic of' },
|
||||
{ code: 'IQ', name: 'Iraq' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'IM', name: 'Isle of Man' },
|
||||
{ code: 'IL', name: 'Israel' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'JM', name: 'Jamaica' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'JE', name: 'Jersey' },
|
||||
{ code: 'JO', name: 'Jordan' },
|
||||
{ code: 'KZ', name: 'Kazakhstan' },
|
||||
{ code: 'KE', name: 'Kenya' },
|
||||
{ code: 'KI', name: 'Kiribati' },
|
||||
{ code: 'KP', name: "Korea, Democratic People's Republic of" },
|
||||
{ code: 'KR', name: 'Korea, Republic of' },
|
||||
{ code: 'KW', name: 'Kuwait' },
|
||||
{ code: 'KG', name: 'Kyrgyzstan' },
|
||||
{ code: 'LA', name: "Lao People's Democratic Republic" },
|
||||
{ code: 'LV', name: 'Latvia' },
|
||||
{ code: 'LB', name: 'Lebanon' },
|
||||
{ code: 'LS', name: 'Lesotho' },
|
||||
{ code: 'LR', name: 'Liberia' },
|
||||
{ code: 'LY', name: 'Libyan Arab Jamahiriya' },
|
||||
{ code: 'LI', name: 'Liechtenstein' },
|
||||
{ code: 'LT', name: 'Lithuania' },
|
||||
{ code: 'LU', name: 'Luxembourg' },
|
||||
{ code: 'MO', name: 'Macao' },
|
||||
{ code: 'MK', name: 'Macedonia, The Former Yugoslav Republic of' },
|
||||
{ code: 'MG', name: 'Madagascar' },
|
||||
{ code: 'MW', name: 'Malawi' },
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'MV', name: 'Maldives' },
|
||||
{ code: 'ML', name: 'Mali' },
|
||||
{ code: 'MT', name: 'Malta' },
|
||||
{ code: 'MH', name: 'Marshall Islands' },
|
||||
{ code: 'MQ', name: 'Martinique' },
|
||||
{ code: 'MR', name: 'Mauritania' },
|
||||
{ code: 'MU', name: 'Mauritius' },
|
||||
{ code: 'YT', name: 'Mayotte' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'FM', name: 'Micronesia, Federated States of' },
|
||||
{ code: 'MD', name: 'Moldova' },
|
||||
{ code: 'MC', name: 'Monaco' },
|
||||
{ code: 'MN', name: 'Mongolia' },
|
||||
{ code: 'ME', name: 'Montenegro' },
|
||||
{ code: 'MS', name: 'Montserrat' },
|
||||
{ code: 'MA', name: 'Morocco' },
|
||||
{ code: 'MZ', name: 'Mozambique' },
|
||||
{ code: 'MM', name: 'Myanmar' },
|
||||
{ code: 'NA', name: 'Namibia' },
|
||||
{ code: 'NR', name: 'Nauru' },
|
||||
{ code: 'NP', name: 'Nepal' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'AN', name: 'Netherlands Antilles' },
|
||||
{ code: 'NC', name: 'New Caledonia' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'NI', name: 'Nicaragua' },
|
||||
{ code: 'NE', name: 'Niger' },
|
||||
{ code: 'NG', name: 'Nigeria' },
|
||||
{ code: 'NU', name: 'Niue' },
|
||||
{ code: 'NF', name: 'Norfolk Island' },
|
||||
{ code: 'MP', name: 'Northern Mariana Islands' },
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'OM', name: 'Oman' },
|
||||
{ code: 'PK', name: 'Pakistan' },
|
||||
{ code: 'PW', name: 'Palau' },
|
||||
{ code: 'PS', name: 'Palestinian Territory, Occupied' },
|
||||
{ code: 'PA', name: 'Panama' },
|
||||
{ code: 'PG', name: 'Papua New Guinea' },
|
||||
{ code: 'PY', name: 'Paraguay' },
|
||||
{ code: 'PE', name: 'Peru' },
|
||||
{ code: 'PH', name: 'Philippines' },
|
||||
{ code: 'PN', name: 'Pitcairn' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'PR', name: 'Puerto Rico' },
|
||||
{ code: 'QA', name: 'Qatar' },
|
||||
{ code: 'RE', name: 'Reunion' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
{ code: 'RU', name: 'Russian Federation' },
|
||||
{ code: 'RW', name: 'Rwanda' },
|
||||
{ code: 'BL', name: 'Saint Barthelemy' },
|
||||
{ code: 'SH', name: 'Saint Helena' },
|
||||
{ code: 'KN', name: 'Saint Kitts and Nevis' },
|
||||
{ code: 'LC', name: 'Saint Lucia' },
|
||||
{ code: 'MF', name: 'Saint Martin' },
|
||||
{ code: 'PM', name: 'Saint Pierre and Miquelon' },
|
||||
{ code: 'VC', name: 'Saint Vincent and the Grenadines' },
|
||||
{ code: 'WS', name: 'Samoa' },
|
||||
{ code: 'SM', name: 'San Marino' },
|
||||
{ code: 'ST', name: 'Sao Tome and Principe' },
|
||||
{ code: 'SA', name: 'Saudi Arabia' },
|
||||
{ code: 'SN', name: 'Senegal' },
|
||||
{ code: 'RS', name: 'Serbia' },
|
||||
{ code: 'SC', name: 'Seychelles' },
|
||||
{ code: 'SL', name: 'Sierra Leone' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'SK', name: 'Slovakia' },
|
||||
{ code: 'SI', name: 'Slovenia' },
|
||||
{ code: 'SB', name: 'Solomon Islands' },
|
||||
{ code: 'SO', name: 'Somalia' },
|
||||
{ code: 'ZA', name: 'South Africa' },
|
||||
{ code: 'GS', name: 'South Georgia and the South Sandwich Islands' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'LK', name: 'Sri Lanka' },
|
||||
{ code: 'SD', name: 'Sudan' },
|
||||
{ code: 'SR', name: 'Suriname' },
|
||||
{ code: 'SJ', name: 'Svalbard and Jan Mayen' },
|
||||
{ code: 'SZ', name: 'Swaziland' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'CH', name: 'Switzerland' },
|
||||
{ code: 'SY', name: 'Syrian Arab Republic' },
|
||||
{ code: 'TW', name: 'Taiwan, Province of China' },
|
||||
{ code: 'TJ', name: 'Tajikistan' },
|
||||
{ code: 'TZ', name: 'Tanzania, United Republic of' },
|
||||
{ code: 'TH', name: 'Thailand' },
|
||||
{ code: 'TL', name: 'Timor-Leste' },
|
||||
{ code: 'TG', name: 'Togo' },
|
||||
{ code: 'TK', name: 'Tokelau' },
|
||||
{ code: 'TO', name: 'Tonga' },
|
||||
{ code: 'TT', name: 'Trinidad and Tobago' },
|
||||
{ code: 'TN', name: 'Tunisia' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'TM', name: 'Turkmenistan' },
|
||||
{ code: 'TC', name: 'Turks and Caicos Islands' },
|
||||
{ code: 'TV', name: 'Tuvalu' },
|
||||
{ code: 'UG', name: 'Uganda' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'UM', name: 'United States Minor Outlying Islands' },
|
||||
{ code: 'UY', name: 'Uruguay' },
|
||||
{ code: 'UZ', name: 'Uzbekistan' },
|
||||
{ code: 'VU', name: 'Vanuatu' },
|
||||
{ code: 'VE', name: 'Venezuela' },
|
||||
{ code: 'VN', name: 'Viet Nam' },
|
||||
{ code: 'VG', name: 'Virgin Islands, British' },
|
||||
{ code: 'VI', name: 'Virgin Islands, U.S.' },
|
||||
{ code: 'WF', name: 'Wallis and Futuna' },
|
||||
{ code: 'EH', name: 'Western Sahara' },
|
||||
{ code: 'YE', name: 'Yemen' },
|
||||
{ code: 'ZM', name: 'Zambia' },
|
||||
{ code: 'ZW', name: 'Zimbabwe' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Get country name by code
|
||||
* @param {string} code - Country code (e.g., 'US')
|
||||
* @returns {string} Country name or the code if not found
|
||||
*/
|
||||
export function getCountryName(code) {
|
||||
if (!code) return '';
|
||||
const country = COUNTRIES.find((c) => c.code === code);
|
||||
return country?.name || code;
|
||||
}
|
||||
130
frontend/src/lib/constants/filters.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Filter option constants for list pages
|
||||
* @module lib/constants/filters
|
||||
*/
|
||||
|
||||
/** @type {{ value: string, label: string }[]} */
|
||||
export const LEAD_STATUSES = [
|
||||
{ value: 'ALL', label: 'All Statuses' },
|
||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||
{ value: 'IN_PROCESS', label: 'In Process' },
|
||||
{ value: 'CONVERTED', label: 'Converted' },
|
||||
{ value: 'RECYCLED', label: 'Recycled' },
|
||||
{ value: 'CLOSED', label: 'Closed' }
|
||||
];
|
||||
|
||||
/** @type {{ value: string, label: string }[]} */
|
||||
export const LEAD_SOURCES = [
|
||||
{ value: 'ALL', label: 'All Sources' },
|
||||
{ value: 'call', label: 'Call' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'existing customer', label: 'Existing Customer' },
|
||||
{ value: 'partner', label: 'Partner' },
|
||||
{ value: 'public relations', label: 'Public Relations' },
|
||||
{ value: 'compaign', label: 'Campaign' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
/** @type {{ value: string, label: string }[]} */
|
||||
export const LEAD_RATINGS = [
|
||||
{ value: 'ALL', label: 'All Ratings' },
|
||||
{ value: 'HOT', label: 'Hot' },
|
||||
{ value: 'WARM', label: 'Warm' },
|
||||
{ value: 'COLD', label: 'Cold' }
|
||||
];
|
||||
|
||||
/** @type {{ value: string, label: string }[]} */
|
||||
export const CASE_STATUSES = [
|
||||
{ value: 'ALL', label: 'All Statuses' },
|
||||
{ value: 'New', label: 'New' },
|
||||
{ value: 'Assigned', label: 'Assigned' },
|
||||
{ value: 'Pending', label: 'Pending' },
|
||||
{ value: 'Closed', label: 'Closed' },
|
||||
{ value: 'Rejected', label: 'Rejected' },
|
||||
{ value: 'Duplicate', label: 'Duplicate' }
|
||||
];
|
||||
|
||||
/** @type {{ value: string, label: string }[]} */
|
||||
export const CASE_TYPES = [
|
||||
{ value: '', label: 'Select Type' },
|
||||
{ value: 'Question', label: 'Question' },
|
||||
{ value: 'Incident', label: 'Incident' },
|
||||
{ value: 'Problem', label: 'Problem' }
|
||||
];
|
||||
|
||||
/** @type {{ value: string, label: string }[]} */
|
||||
export const PRIORITIES = [
|
||||
{ value: 'ALL', label: 'All Priorities' },
|
||||
{ value: 'High', label: 'High' },
|
||||
{ value: 'Medium', label: 'Medium' },
|
||||
{ value: 'Low', label: 'Low' }
|
||||
];
|
||||
|
||||
/** @type {{ value: string, label: string }[]} */
|
||||
export const TASK_STATUSES = [
|
||||
{ value: 'ALL', label: 'All Statuses' },
|
||||
{ value: 'New', label: 'New' },
|
||||
{ value: 'In Progress', label: 'In Progress' },
|
||||
{ value: 'Completed', label: 'Completed' }
|
||||
];
|
||||
|
||||
/** @type {{ value: string, label: string }[]} */
|
||||
export const OPPORTUNITY_TYPES = [
|
||||
{ value: '', label: 'Select Type' },
|
||||
{ value: 'NEW_BUSINESS', label: 'New Business' },
|
||||
{ value: 'EXISTING_BUSINESS', label: 'Existing Business' },
|
||||
{ value: 'RENEWAL', label: 'Renewal' },
|
||||
{ value: 'UPSELL', label: 'Upsell' },
|
||||
{ value: 'CROSS_SELL', label: 'Cross-sell' }
|
||||
];
|
||||
|
||||
/** @type {{ value: string, label: string }[]} */
|
||||
export const OPPORTUNITY_SOURCES = [
|
||||
{ value: '', label: 'Select Source' },
|
||||
{ value: 'NONE', label: 'None' },
|
||||
{ value: 'CALL', label: 'Call' },
|
||||
{ value: 'EMAIL', label: 'Email' },
|
||||
{ value: 'EXISTING CUSTOMER', label: 'Existing Customer' },
|
||||
{ value: 'PARTNER', label: 'Partner' },
|
||||
{ value: 'PUBLIC RELATIONS', label: 'Public Relations' },
|
||||
{ value: 'CAMPAIGN', label: 'Campaign' },
|
||||
{ value: 'WEBSITE', label: 'Website' },
|
||||
{ value: 'OTHER', label: 'Other' }
|
||||
];
|
||||
|
||||
/** @type {{ value: string, label: string }[]} */
|
||||
export const CURRENCY_CODES = [
|
||||
{ value: '', label: 'Select Currency' },
|
||||
{ value: 'USD', label: 'USD - Dollar' },
|
||||
{ value: 'EUR', label: 'EUR - Euro' },
|
||||
{ value: 'GBP', label: 'GBP - Pound' },
|
||||
{ value: 'INR', label: 'INR - Rupee' },
|
||||
{ value: 'CAD', label: 'CAD - Dollar' },
|
||||
{ value: 'AUD', label: 'AUD - Dollar' },
|
||||
{ value: 'JPY', label: 'JPY - Yen' },
|
||||
{ value: 'CNY', label: 'CNY - Yuan' },
|
||||
{ value: 'CHF', label: 'CHF - Franc' },
|
||||
{ value: 'SGD', label: 'SGD - Dollar' },
|
||||
{ value: 'AED', label: 'AED - Dirham' },
|
||||
{ value: 'BRL', label: 'BRL - Real' },
|
||||
{ value: 'MXN', label: 'MXN - Peso' },
|
||||
{ value: 'ZAR', label: 'ZAR - Rand' }
|
||||
];
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
export const CURRENCY_SYMBOLS = {
|
||||
USD: '$',
|
||||
EUR: '€',
|
||||
GBP: '£',
|
||||
INR: '₹',
|
||||
CAD: 'CA$',
|
||||
AUD: 'A$',
|
||||
JPY: '¥',
|
||||
CNY: '¥',
|
||||
CHF: 'CHF',
|
||||
SGD: 'S$',
|
||||
AED: 'د.إ',
|
||||
BRL: 'R$',
|
||||
MXN: 'MX$',
|
||||
ZAR: 'R'
|
||||
};
|
||||
105
frontend/src/lib/import/entities.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* What `ImportFlow` needs to know about each importable record.
|
||||
*
|
||||
* - `singular`, `plural`: default words for the record; the leads drawer
|
||||
* passes the org's own terminology instead.
|
||||
* - `actionPath`: the list page whose form actions (`?/importPreview`,
|
||||
* `?/importCommit`) forward the upload to Django, so the flow works from any
|
||||
* page, not only from that list.
|
||||
* - `listHref`: where the imported records are listed.
|
||||
* - `templateHeaders`, `templateExample`: the downloadable template, one
|
||||
* header row and one example row. The headers are the importer's own field
|
||||
* names, so a filled-in template matches itself.
|
||||
*/
|
||||
export const IMPORT_ENTITIES = {
|
||||
contacts: {
|
||||
singular: 'contact',
|
||||
plural: 'contacts',
|
||||
actionPath: '/contacts',
|
||||
listHref: '/contacts',
|
||||
templateHeaders: [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'phone',
|
||||
'organization',
|
||||
'title',
|
||||
'department',
|
||||
'do_not_call',
|
||||
'linkedin_url',
|
||||
'address_line',
|
||||
'city',
|
||||
'state',
|
||||
'postcode',
|
||||
'country',
|
||||
'description',
|
||||
'account_name',
|
||||
'assigned_emails',
|
||||
'team_names',
|
||||
'tags'
|
||||
],
|
||||
templateExample: [
|
||||
'Alice',
|
||||
'Smith',
|
||||
'alice@acme.test',
|
||||
'+1 (555) 123-4567',
|
||||
'Acme Corp',
|
||||
'VP of Sales',
|
||||
'Sales',
|
||||
'no',
|
||||
'https://linkedin.com/in/alice',
|
||||
'500 Main St',
|
||||
'Austin',
|
||||
'TX',
|
||||
'78701',
|
||||
'US',
|
||||
'Met at SaaStr 2024',
|
||||
'Acme Corp',
|
||||
'agent@yourco.com',
|
||||
'Sales',
|
||||
'vip;newsletter'
|
||||
]
|
||||
},
|
||||
leads: {
|
||||
singular: 'lead',
|
||||
plural: 'leads',
|
||||
actionPath: '/leads',
|
||||
listHref: '/leads',
|
||||
templateHeaders: [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'phone',
|
||||
'title',
|
||||
'company_name',
|
||||
'job_title',
|
||||
'status',
|
||||
'source',
|
||||
'rating',
|
||||
'opportunity_amount',
|
||||
'close_date',
|
||||
'description',
|
||||
'assigned_emails',
|
||||
'team_names',
|
||||
'tags'
|
||||
],
|
||||
templateExample: [
|
||||
'Alice',
|
||||
'Smith',
|
||||
'alice@acme.test',
|
||||
'+1 (555) 123-4567',
|
||||
'Enterprise rollout',
|
||||
'Acme Corp',
|
||||
'VP of Sales',
|
||||
'assigned',
|
||||
'email',
|
||||
'HOT',
|
||||
'12000',
|
||||
'2026-12-31',
|
||||
'Met at SaaStr',
|
||||
'agent@yourco.com',
|
||||
'Sales',
|
||||
'vip;trade-show'
|
||||
]
|
||||
}
|
||||
};
|
||||
132
frontend/src/lib/import/mapping.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* The column-matching step of a spreadsheet import, as plain functions.
|
||||
*
|
||||
* `ImportFlow.svelte` renders these; they stay free of Svelte and `$app/*` so
|
||||
* vitest can run them. The shapes are the server's preview reply
|
||||
* (`common/import_mapping.py`): a column is `{ index, header, field, samples }`
|
||||
* with `field` a field name or null ("Don't import"), and a skipped row is
|
||||
* `{ row, field, message, cells, kind }`, where `kind` is `duplicate` (the
|
||||
* record is already in the org, or earlier in the file) or `row` (the row
|
||||
* cannot become a record). The helpers read `kind`; `message` is for people.
|
||||
*/
|
||||
import { csvCell } from '#lib/utils/csv.js';
|
||||
|
||||
/** @typedef {{ index: number, header?: string, field: string | null, samples?: string[] }} Column */
|
||||
/** @typedef {{ row: number, field: string, message: string, cells: string[], kind: 'duplicate' | 'row' }} Skipped */
|
||||
/** @typedef {{ name: string, label: string, required?: boolean }} Field */
|
||||
|
||||
const NAME_FIELDS = new Set(['first_name', 'last_name']);
|
||||
|
||||
/**
|
||||
* Put `field` on the column at `index`. A field goes to one column only, so a
|
||||
* column that already held it is set to "Don't import" (null).
|
||||
*
|
||||
* @template {Column} T
|
||||
* @param {T[]} columns
|
||||
* @param {number} index
|
||||
* @param {string | null} field
|
||||
* @returns {T[]} a new array; `columns` is not changed
|
||||
*/
|
||||
export function setColumnField(columns, index, field) {
|
||||
return columns.map((c) => {
|
||||
if (c.index === index) return { ...c, field };
|
||||
if (field && c.field === field) return { ...c, field: null };
|
||||
return c;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `mapping` form field for an explicit choice: `{"<index>": field | null}`.
|
||||
*
|
||||
* @param {Column[]} columns
|
||||
*/
|
||||
export function mappingPayload(columns) {
|
||||
return JSON.stringify(Object.fromEntries(columns.map((c) => [String(c.index), c.field ?? null])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any column is matched to a field. False usually means the first row
|
||||
* of the sheet is a title rather than the column names.
|
||||
*
|
||||
* @param {Column[]} columns
|
||||
*/
|
||||
export function hasAnyMatch(columns) {
|
||||
return columns.some((c) => Boolean(c.field));
|
||||
}
|
||||
|
||||
/**
|
||||
* One sentence per dropped field, named by its label.
|
||||
*
|
||||
* @param {{ field: string, count: number }[]} dropped
|
||||
* @param {Field[]} fields
|
||||
*/
|
||||
export function droppedLines(dropped, fields) {
|
||||
const labels = new Map(fields.map((f) => [f.name, f.label]));
|
||||
return dropped.map(({ field, count }) => {
|
||||
const label = labels.get(field) ?? field;
|
||||
return count === 1
|
||||
? `${label}: 1 value we couldn't use was left blank.`
|
||||
: `${label}: ${count} values we couldn't use were left blank.`;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {number} n */
|
||||
function rows(n) {
|
||||
return `${n} ${n === 1 ? 'row' : 'rows'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentence above the Import button: what importing this file will do.
|
||||
*
|
||||
* @param {{ singular: string, plural: string }} nouns the org's words for the record
|
||||
* @param {{ skipped: Skipped[], summary: { total: number, valid: number, skipped: number } }} preview
|
||||
*/
|
||||
export function summaryLine(nouns, preview) {
|
||||
const { total, valid, skipped } = preview.summary;
|
||||
const duplicates =
|
||||
preview.skipped.length > 0 && preview.skipped.every((s) => s.kind === 'duplicate');
|
||||
if (valid === 0) {
|
||||
if (total === 0) return 'This file has no rows to import.';
|
||||
// With nothing importable there is no earlier row to duplicate, so every
|
||||
// duplicate here is a record already in the org.
|
||||
if (duplicates) return 'Everything in this file is already in BottleCRM.';
|
||||
return 'Nothing in this file can be imported.';
|
||||
}
|
||||
const head = `${valid} ${valid === 1 ? nouns.singular : nouns.plural} will be imported`;
|
||||
if (skipped === 0) return `${head}.`;
|
||||
if (duplicates)
|
||||
return `${head}, and ${skipped} duplicate ${skipped === 1 ? 'row' : 'rows'} will be skipped.`;
|
||||
return `${head}, and ${rows(skipped)} will be skipped.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to offer importing the file as leads instead: a contact needs a
|
||||
* first and a last name, a lead needs only one. A name skipped as a duplicate
|
||||
* does not count, since importing it as a lead would duplicate the person.
|
||||
*
|
||||
* @param {string} entityKey `contacts` or `leads`
|
||||
* @param {Skipped[]} skipped
|
||||
*/
|
||||
export function suggestLeads(entityKey, skipped) {
|
||||
return (
|
||||
entityKey === 'contacts' && skipped.some((s) => s.kind === 'row' && NAME_FIELDS.has(s.field))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The skipped rows as a CSV the user can fix and upload again: the file's own
|
||||
* headers and cells, plus a `Reason` column. Short rows are padded so the
|
||||
* reason always lands under `Reason`. Every cell goes through `csvCell`, so a
|
||||
* cell that would run as a formula is neutralised.
|
||||
*
|
||||
* @param {string[]} headers
|
||||
* @param {Skipped[]} skipped
|
||||
*/
|
||||
export function skippedRowsCsv(headers, skipped) {
|
||||
const width = skipped.reduce((w, s) => Math.max(w, s.cells.length), headers.length);
|
||||
/** @param {string[]} cells */
|
||||
const pad = (cells) => Array.from({ length: width }, (_, i) => cells[i] ?? '');
|
||||
return [[...pad(headers), 'Reason'], ...skipped.map((s) => [...pad(s.cells), s.message])]
|
||||
.map((cells) => cells.map(csvCell).join(','))
|
||||
.join('\n');
|
||||
}
|
||||
300
frontend/src/lib/import/mapping.test.js
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
droppedLines,
|
||||
hasAnyMatch,
|
||||
mappingPayload,
|
||||
setColumnField,
|
||||
skippedRowsCsv,
|
||||
suggestLeads,
|
||||
summaryLine
|
||||
} from './mapping.js';
|
||||
|
||||
/** @param {number} index @param {string | null} field */
|
||||
const col = (index, field) => ({ index, header: `H${index}`, field, samples: [] });
|
||||
|
||||
const CONTACT = { singular: 'contact', plural: 'contacts' };
|
||||
|
||||
/**
|
||||
* A preview body as the server sends it, reduced to what summaryLine reads.
|
||||
* @param {number} valid
|
||||
* @param {import('./mapping.js').Skipped[]} skipped
|
||||
*/
|
||||
function preview(valid, skipped = []) {
|
||||
return {
|
||||
valid: Array.from({ length: valid }, (_, i) => ({ row: i + 1 })),
|
||||
skipped,
|
||||
summary: { total: valid + skipped.length, valid, skipped: skipped.length }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A skipped row as the server sends it. `kind` is `duplicate` (already in the
|
||||
* org or earlier in the file) or `row` (cannot become a record).
|
||||
* @param {number} row @param {string} field @param {'duplicate' | 'row'} kind @param {string} [message]
|
||||
* @returns {import('./mapping.js').Skipped}
|
||||
*/
|
||||
const skip = (row, field, kind, message = 'Skipped') => ({ row, field, message, cells: [], kind });
|
||||
|
||||
describe('setColumnField', () => {
|
||||
it('sets the field on the chosen column and returns a new array', () => {
|
||||
const columns = [col(0, null), col(1, 'phone')];
|
||||
const next = setColumnField(columns, 0, 'email');
|
||||
expect(next).not.toBe(columns);
|
||||
expect(next.map((c) => c.field)).toEqual(['email', 'phone']);
|
||||
expect(columns.map((c) => c.field)).toEqual([null, 'phone']);
|
||||
});
|
||||
|
||||
it('moves a field off the column that held it', () => {
|
||||
const columns = [col(0, 'email'), col(1, null), col(2, 'phone')];
|
||||
expect(setColumnField(columns, 1, 'email').map((c) => c.field)).toEqual([
|
||||
null,
|
||||
'email',
|
||||
'phone'
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets a column to Don't import without touching the others", () => {
|
||||
const columns = [col(0, 'email'), col(1, 'phone')];
|
||||
expect(setColumnField(columns, 0, null).map((c) => c.field)).toEqual([null, 'phone']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mappingPayload', () => {
|
||||
it('is a JSON object of column index to field or null', () => {
|
||||
expect(
|
||||
mappingPayload([
|
||||
{ index: 0, field: 'email' },
|
||||
{ index: 1, field: null }
|
||||
])
|
||||
).toBe('{"0":"email","1":null}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAnyMatch', () => {
|
||||
it('is false when every column is set to null', () => {
|
||||
expect(hasAnyMatch([col(0, null), col(1, null)])).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when any column has a field', () => {
|
||||
expect(hasAnyMatch([col(0, null), col(1, 'email')])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('droppedLines', () => {
|
||||
const fields = [
|
||||
{ name: 'phone', label: 'Phone', required: false },
|
||||
{ name: 'linkedin_url', label: 'LinkedIn URL', required: false }
|
||||
];
|
||||
|
||||
it("gives one line per entry, each with the count and the field's label", () => {
|
||||
const lines = droppedLines(
|
||||
[
|
||||
{ field: 'linkedin_url', count: 1 },
|
||||
{ field: 'phone', count: 12 }
|
||||
],
|
||||
fields
|
||||
);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]).toContain('1');
|
||||
expect(lines[0]).toContain('LinkedIn URL');
|
||||
expect(lines[1]).toContain('12');
|
||||
expect(lines[1]).toContain('Phone');
|
||||
});
|
||||
|
||||
it('agrees in number with the count', () => {
|
||||
const [one, many] = droppedLines(
|
||||
[
|
||||
{ field: 'phone', count: 1 },
|
||||
{ field: 'phone', count: 3 }
|
||||
],
|
||||
fields
|
||||
);
|
||||
expect(one).toBe("Phone: 1 value we couldn't use was left blank.");
|
||||
expect(many).toBe("Phone: 3 values we couldn't use were left blank.");
|
||||
});
|
||||
|
||||
it('falls back to the field name when the label is unknown', () => {
|
||||
expect(droppedLines([{ field: 'country', count: 2 }], fields)[0]).toContain('country');
|
||||
});
|
||||
|
||||
it('is empty for nothing dropped', () => {
|
||||
expect(droppedLines([], fields)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summaryLine', () => {
|
||||
it('says everything is already in BottleCRM when every skipped row is a duplicate', () => {
|
||||
const line = summaryLine(
|
||||
CONTACT,
|
||||
preview(0, [skip(1, 'email', 'duplicate'), skip(2, 'phone', 'duplicate')])
|
||||
);
|
||||
expect(line).toBe('Everything in this file is already in BottleCRM.');
|
||||
});
|
||||
|
||||
it('says the same for a lead file imported twice', () => {
|
||||
const line = summaryLine(
|
||||
{ singular: 'prospect', plural: 'prospects' },
|
||||
preview(0, [skip(1, 'email', 'duplicate')])
|
||||
);
|
||||
expect(line).toBe('Everything in this file is already in BottleCRM.');
|
||||
});
|
||||
|
||||
it('reads the kind, not the message', () => {
|
||||
expect(
|
||||
summaryLine(CONTACT, preview(0, [skip(1, 'email', 'duplicate', 'Worded differently')]))
|
||||
).toBe('Everything in this file is already in BottleCRM.');
|
||||
expect(
|
||||
summaryLine(CONTACT, preview(0, [skip(1, 'last_name', 'row', 'This already exists')]))
|
||||
).toBe('Nothing in this file can be imported.');
|
||||
});
|
||||
|
||||
it('does not claim the rows exist when any was skipped for another reason', () => {
|
||||
const line = summaryLine(
|
||||
CONTACT,
|
||||
preview(0, [skip(1, 'email', 'duplicate'), skip(2, 'last_name', 'row')])
|
||||
);
|
||||
expect(line).toBe('Nothing in this file can be imported.');
|
||||
});
|
||||
|
||||
it("counts the records with the caller's own words", () => {
|
||||
expect(summaryLine({ singular: 'prospect', plural: 'prospects' }, preview(1))).toBe(
|
||||
'1 prospect will be imported.'
|
||||
);
|
||||
expect(summaryLine(CONTACT, preview(12))).toBe('12 contacts will be imported.');
|
||||
});
|
||||
|
||||
it('counts the skipped rows beside the imported ones', () => {
|
||||
expect(summaryLine(CONTACT, preview(3, [skip(4, 'last_name', 'row')]))).toBe(
|
||||
'3 contacts will be imported, and 1 row will be skipped.'
|
||||
);
|
||||
});
|
||||
|
||||
it('says when every skipped row is a duplicate', () => {
|
||||
expect(
|
||||
summaryLine(
|
||||
CONTACT,
|
||||
preview(2, [skip(3, 'email', 'duplicate'), skip(4, 'email', 'duplicate')])
|
||||
)
|
||||
).toBe('2 contacts will be imported, and 2 duplicate rows will be skipped.');
|
||||
});
|
||||
|
||||
it('says so when the file has no rows', () => {
|
||||
expect(summaryLine(CONTACT, preview(0))).toBe('This file has no rows to import.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestLeads', () => {
|
||||
it('is true for contacts with a row skipped for its name', () => {
|
||||
expect(suggestLeads('contacts', [skip(2, 'last_name', 'row')])).toBe(true);
|
||||
expect(suggestLeads('contacts', [skip(2, 'first_name', 'row')])).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for leads, which accept one name', () => {
|
||||
expect(suggestLeads('leads', [skip(2, 'first_name', 'row')])).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for contacts skipped for anything but the name', () => {
|
||||
expect(suggestLeads('contacts', [skip(2, 'email', 'row')])).toBe(false);
|
||||
expect(suggestLeads('contacts', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a name that is skipped as a duplicate', () => {
|
||||
// A lead would duplicate the contact that already holds this name.
|
||||
expect(
|
||||
suggestLeads('contacts', [
|
||||
skip(2, 'first_name', 'duplicate'),
|
||||
skip(3, 'last_name', 'duplicate')
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('skippedRowsCsv', () => {
|
||||
it('keeps the original headers and cells in order and appends the reason', () => {
|
||||
const csv = skippedRowsCsv(
|
||||
['Name', 'Mobile', 'Email ID'],
|
||||
[
|
||||
{
|
||||
row: 2,
|
||||
field: 'last_name',
|
||||
message: 'Last name is required',
|
||||
cells: ['Ada', '5550100', 'ada@x.test'],
|
||||
kind: 'row'
|
||||
}
|
||||
]
|
||||
);
|
||||
expect(csv).toBe(
|
||||
'Name,Mobile,Email ID,Reason\n' + 'Ada,5550100,ada@x.test,Last name is required'
|
||||
);
|
||||
});
|
||||
|
||||
it('neutralises a cell a spreadsheet would run as a formula', () => {
|
||||
const csv = skippedRowsCsv(
|
||||
['Name', 'Notes'],
|
||||
[
|
||||
{
|
||||
row: 1,
|
||||
field: 'last_name',
|
||||
message: 'Last name is required',
|
||||
cells: ['Ada', '=HYPERLINK("x")'],
|
||||
kind: 'row'
|
||||
}
|
||||
]
|
||||
);
|
||||
expect(csv.split('\n')[1]).toBe(`Ada,"'=HYPERLINK(""x"")",Last name is required`);
|
||||
});
|
||||
|
||||
it('quotes a reason that holds a comma', () => {
|
||||
const csv = skippedRowsCsv(
|
||||
['Name'],
|
||||
[
|
||||
{
|
||||
row: 1,
|
||||
field: 'first_name',
|
||||
message: 'Same name as row 1 in this file; add an email or phone, please',
|
||||
cells: ['Ada Lovelace'],
|
||||
kind: 'row'
|
||||
}
|
||||
]
|
||||
);
|
||||
expect(csv.split('\n')[1]).toBe(
|
||||
'Ada Lovelace,"Same name as row 1 in this file; add an email or phone, please"'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the reason in its own column for short and long rows', () => {
|
||||
const csv = skippedRowsCsv(
|
||||
['A', 'B'],
|
||||
[
|
||||
{ row: 1, field: 'last_name', message: 'short', cells: ['a'], kind: 'row' },
|
||||
{ row: 2, field: 'last_name', message: 'long', cells: ['a', 'b', 'c'], kind: 'row' }
|
||||
]
|
||||
);
|
||||
expect(csv.split('\n')).toEqual(['A,B,,Reason', 'a,,,short', 'a,b,c,long']);
|
||||
});
|
||||
|
||||
it('guards a + phone with exactly the apostrophe the importer takes off', () => {
|
||||
// The backend reader (`common/spreadsheet.py`, `_FORMULA_GUARD`) removes one
|
||||
// leading apostrophe from a cell that, after its apostrophes, starts with
|
||||
// = + - or @. Re-uploading this file must give back the original phone.
|
||||
const FORMULA_GUARD = /^'+[=+\-@]/;
|
||||
const phone = '+44 20 7946 0000';
|
||||
const csv = skippedRowsCsv(
|
||||
['Name', 'Phone'],
|
||||
[
|
||||
{
|
||||
row: 1,
|
||||
field: 'last_name',
|
||||
message: 'Last name is required',
|
||||
cells: ['Ada', phone],
|
||||
kind: 'row'
|
||||
}
|
||||
]
|
||||
);
|
||||
const guarded = csv.split('\n')[1].split(',')[1];
|
||||
expect(guarded[0]).toBe("'");
|
||||
expect(guarded).toBe(`'${phone}`);
|
||||
expect(FORMULA_GUARD.test(guarded)).toBe(true);
|
||||
expect(guarded.slice(1)).toBe(phone);
|
||||
});
|
||||
});
|
||||
185
frontend/src/lib/server/help-center.js
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* Server-side calls to `/api/public/help/`, an org's public help center.
|
||||
*
|
||||
* Anonymous on purpose, and deliberately NOT built on `apiRequest`: that helper
|
||||
* attaches the visitor's `jwt_access` cookie, and a staff member browsing their
|
||||
* own org's public page must see exactly what a stranger sees. The API ignores
|
||||
* credentials there anyway; not sending one keeps the two facts in agreement.
|
||||
*
|
||||
* Every miss (no such address, a help center switched off, an unpublished or
|
||||
* foreign article) arrives from the API as one identical 404 and leaves here as
|
||||
* one identical SvelteKit 404, so a page cannot be used to probe which it was.
|
||||
*/
|
||||
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import * as env from '$app/env/public';
|
||||
import { relayHeaders } from '#lib/server/relay.js';
|
||||
|
||||
const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api/public/help`;
|
||||
|
||||
/** Articles per list page. Matches the API's default page size. */
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
/** The API's `max_limit`, used when the sitemap walks every article. */
|
||||
const SITEMAP_PAGE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* A sitemap file holds at most 50,000 URLs. Stopping well short of that also
|
||||
* bounds how many API calls one anonymous request can cause.
|
||||
*/
|
||||
const SITEMAP_MAX_PAGES = 50;
|
||||
|
||||
// The backend's slug rule (`validate_help_center_slug`), repeated here only so
|
||||
// an address that could never exist is answered without a round trip. The API
|
||||
// is still the authority: a well-formed slug nobody owns is its 404, not ours.
|
||||
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const notFound = () => error(404, 'Not found');
|
||||
|
||||
/**
|
||||
* The slug a request should be served under, or a 404/redirect.
|
||||
*
|
||||
* Slugs are stored lowercase and the API matches exactly, so `/help-center/Acme`
|
||||
* is sent to `/help-center/acme` with a permanent redirect rather than served
|
||||
* twice under two addresses.
|
||||
*
|
||||
* @param {string} slug
|
||||
* @param {URL} url
|
||||
*/
|
||||
export function canonicalSlug(slug, url) {
|
||||
const lower = slug.toLowerCase();
|
||||
if (lower.length < 3 || lower.length > 50 || !SLUG_RE.test(lower)) throw notFound();
|
||||
if (lower !== slug) {
|
||||
const rest = url.pathname.slice(`/help-center/${slug}`.length);
|
||||
throw redirect(301, `/help-center/${lower}${rest}${url.search}`);
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ request: Request, getClientAddress: () => string }} event
|
||||
* @param {string} path
|
||||
*/
|
||||
async function call(event, path) {
|
||||
// The signed visitor address, so the API's per-visitor throttle buckets the
|
||||
// visitor and not this server. See `#lib/server/relay.js`.
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
headers: { Accept: 'application/json', ...relayHeaders(event) }
|
||||
});
|
||||
if (response.status === 404) throw notFound();
|
||||
if (response.status === 429) throw error(429, 'Too many requests. Try again shortly.');
|
||||
if (!response.ok) throw new Error(`help center request failed with ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the article list, optionally narrowed by a search.
|
||||
*
|
||||
* @param {{ request: Request, getClientAddress: () => string }} event
|
||||
* @param {string} slug already canonical
|
||||
* @param {{ q?: string, page?: number }} [options]
|
||||
*/
|
||||
export async function getHelpCenter(event, slug, { q = '', page = 1 } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String((page - 1) * PAGE_SIZE)
|
||||
});
|
||||
if (q) params.set('q', q);
|
||||
const data = await call(event, `/${encodeURIComponent(slug)}/?${params}`);
|
||||
return {
|
||||
name: data.help_center?.name ?? '',
|
||||
articles: data.articles ?? [],
|
||||
count: data.articles_count ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One article. A malformed id is answered here with the same 404 the API gives
|
||||
* for a missing one.
|
||||
*
|
||||
* @param {{ request: Request, getClientAddress: () => string }} event
|
||||
* @param {string} slug already canonical
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHelpArticle(event, slug, id) {
|
||||
if (!UUID_RE.test(id)) throw notFound();
|
||||
const data = await call(event, `/${encodeURIComponent(slug)}/articles/${id}/`);
|
||||
return {
|
||||
name: data.help_center?.name ?? '',
|
||||
article: data.article,
|
||||
related: data.related ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every article, for the sitemap. Walks the API a page at a time, since the
|
||||
* API caps a page at 100 for an anonymous caller.
|
||||
*
|
||||
* @param {{ request: Request, getClientAddress: () => string }} event
|
||||
* @param {string} slug already canonical
|
||||
* @returns {Promise<{ id: string, updated_at?: string }[]>}
|
||||
*/
|
||||
export async function listAllArticles(event, slug) {
|
||||
/** @type {{ id: string, updated_at?: string }[]} */
|
||||
const all = [];
|
||||
for (let pageIndex = 0; pageIndex < SITEMAP_MAX_PAGES; pageIndex += 1) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(SITEMAP_PAGE_SIZE),
|
||||
offset: String(pageIndex * SITEMAP_PAGE_SIZE)
|
||||
});
|
||||
const data = await call(event, `/${encodeURIComponent(slug)}/?${params}`);
|
||||
const articles = data.articles ?? [];
|
||||
all.push(...articles.map((/** @type {any} */ a) => ({ id: a.id, updated_at: a.updated_at })));
|
||||
if (all.length >= (data.articles_count ?? 0) || articles.length === 0) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page number from the query string, or 1. Anything that is not a positive
|
||||
* whole number is treated as the first page rather than as an error.
|
||||
*
|
||||
* @param {string | null} raw
|
||||
*/
|
||||
export function pageNumber(raw) {
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 10000 ? n : 1;
|
||||
}
|
||||
|
||||
/** @param {string} value */
|
||||
function xmlEscape(value) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* The sitemap document: the index page and every article.
|
||||
*
|
||||
* @param {string} origin e.g. https://app.example.com
|
||||
* @param {string} slug
|
||||
* @param {{ id: string, updated_at?: string }[]} articles
|
||||
*/
|
||||
export function sitemapXml(origin, slug, articles) {
|
||||
const base = `${origin}/help-center/${slug}`;
|
||||
const entries = [` <url><loc>${xmlEscape(base)}</loc></url>`];
|
||||
for (const article of articles) {
|
||||
const lastmod = article.updated_at
|
||||
? `<lastmod>${xmlEscape(article.updated_at.slice(0, 10))}</lastmod>`
|
||||
: '';
|
||||
entries.push(
|
||||
` <url><loc>${xmlEscape(`${base}/articles/${article.id}`)}</loc>${lastmod}</url>`
|
||||
);
|
||||
}
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...entries,
|
||||
'</urlset>',
|
||||
''
|
||||
].join('\n');
|
||||
}
|
||||
163
frontend/src/lib/server/help-center.test.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { canonicalSlug, getHelpArticle, getHelpCenter, listAllArticles, pageNumber, sitemapXml } =
|
||||
await import('#lib/server/help-center.js');
|
||||
// The stub that vitest.config.js aliases `$app/env/private` to, so this is the module the code
|
||||
// under test reads. Imported by path because only the stub has a setter.
|
||||
const { setRelaySecret } = await import('../../../test/stubs/app-env-private.js');
|
||||
|
||||
const SECRET = 'r'.repeat(48);
|
||||
|
||||
/** @param {Record<string, string>} [headers] */
|
||||
function event(headers = {}) {
|
||||
return /** @type {any} */ ({
|
||||
request: new Request('http://app.test/help-center/acme', { headers }),
|
||||
getClientAddress: () => '198.51.100.7'
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {number} status @param {any} body */
|
||||
function reply(status, body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
setRelaySecret(undefined);
|
||||
});
|
||||
|
||||
describe('canonicalSlug', () => {
|
||||
const url = (/** @type {string} */ path) => new URL(`http://app.test${path}`);
|
||||
|
||||
it('passes a canonical slug through', () => {
|
||||
expect(canonicalSlug('acme-help', url('/help-center/acme-help'))).toBe('acme-help');
|
||||
});
|
||||
|
||||
it('redirects a mixed-case slug to lowercase, keeping the rest of the URL', () => {
|
||||
try {
|
||||
canonicalSlug('Acme', url('/help-center/Acme/articles/x?q=1'));
|
||||
expect.unreachable();
|
||||
} catch (/** @type {any} */ err) {
|
||||
expect(err.status).toBe(301);
|
||||
expect(err.location).toBe('/help-center/acme/articles/x?q=1');
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['ab', '-acme', 'acme-', 'ac--me', 'ac_me', 'a'.repeat(51)])(
|
||||
'404s an address that could never exist: %s',
|
||||
(slug) => {
|
||||
try {
|
||||
canonicalSlug(slug, url(`/help-center/${slug}`));
|
||||
expect.unreachable();
|
||||
} catch (/** @type {any} */ err) {
|
||||
expect(err.status).toBe(404);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
it('sends no credential and the signed visitor address', async () => {
|
||||
setRelaySecret(SECRET);
|
||||
fetchMock.mockResolvedValue(
|
||||
reply(200, { help_center: { name: 'Acme' }, articles: [], articles_count: 0 })
|
||||
);
|
||||
await getHelpCenter(event({ cookie: 'jwt_access=staff' }), 'acme', { q: 'reset', page: 2 });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('http://localhost:8000/api/public/help/acme/?limit=20&offset=20&q=reset');
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
expect(init.headers['X-BottleCRM-Client-IP']).toBe('198.51.100.7');
|
||||
expect(init.headers['X-BottleCRM-Relay-Secret']).toBe(SECRET);
|
||||
});
|
||||
|
||||
it('never passes on the incoming forwarded chain, which the visitor writes', async () => {
|
||||
setRelaySecret(SECRET);
|
||||
fetchMock.mockResolvedValue(reply(200, { articles: [] }));
|
||||
await getHelpCenter(event({ 'x-forwarded-for': '203.0.113.9' }), 'acme');
|
||||
const { headers } = fetchMock.mock.calls[0][1];
|
||||
// The visitor's address as this server saw it, not the header they sent.
|
||||
expect(headers['X-Forwarded-For']).toBe('198.51.100.7');
|
||||
expect(headers['X-BottleCRM-Client-IP']).toBe('198.51.100.7');
|
||||
});
|
||||
|
||||
it('sends only the pre-1.12 X-Forwarded-For without a relay secret', async () => {
|
||||
fetchMock.mockResolvedValue(reply(200, { articles: [] }));
|
||||
await getHelpCenter(event({ 'x-forwarded-for': '203.0.113.9' }), 'acme');
|
||||
expect(fetchMock.mock.calls[0][1].headers).toEqual({
|
||||
Accept: 'application/json',
|
||||
'X-Forwarded-For': '198.51.100.7'
|
||||
});
|
||||
});
|
||||
|
||||
it('turns the API 404 into a page 404', async () => {
|
||||
fetchMock.mockResolvedValue(reply(404, { error: 'Not found' }));
|
||||
await expect(getHelpCenter(event(), 'acme')).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('404s a malformed article id without calling the API', async () => {
|
||||
await expect(getHelpArticle(event(), 'acme', 'not-a-uuid')).rejects.toMatchObject({
|
||||
status: 404
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a throttled request as 429', async () => {
|
||||
fetchMock.mockResolvedValue(reply(429, {}));
|
||||
await expect(getHelpCenter(event(), 'acme')).rejects.toMatchObject({ status: 429 });
|
||||
});
|
||||
|
||||
it('walks every page for the sitemap and stops at the count', async () => {
|
||||
const page = (/** @type {number} */ n, /** @type {number} */ start) =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: `id-${start + i}`,
|
||||
updated_at: '2026-09-01T00:00:00Z'
|
||||
}));
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(reply(200, { articles: page(100, 0), articles_count: 150 }))
|
||||
.mockResolvedValueOnce(reply(200, { articles: page(50, 100), articles_count: 150 }));
|
||||
const all = await listAllArticles(event(), 'acme');
|
||||
expect(all).toHaveLength(150);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('limit=100&offset=100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pageNumber', () => {
|
||||
it.each([
|
||||
[null, 1],
|
||||
['3', 3],
|
||||
['0', 1],
|
||||
['-2', 1],
|
||||
['1.5', 1],
|
||||
['abc', 1]
|
||||
])('%s -> %s', (raw, expected) => {
|
||||
expect(pageNumber(raw)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sitemapXml', () => {
|
||||
it('lists the index and each article with its date', () => {
|
||||
const xml = sitemapXml('https://app.example.com', 'acme', [
|
||||
{ id: 'a1', updated_at: '2026-09-01T10:00:00Z' }
|
||||
]);
|
||||
expect(xml).toContain('<loc>https://app.example.com/help-center/acme</loc>');
|
||||
expect(xml).toContain(
|
||||
'<loc>https://app.example.com/help-center/acme/articles/a1</loc><lastmod>2026-09-01</lastmod>'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes markup characters', () => {
|
||||
const xml = sitemapXml('https://a.test', 'acme', [{ id: '<x>&' }]);
|
||||
expect(xml).toContain('/articles/<x>&</loc>');
|
||||
});
|
||||
});
|
||||
41
frontend/src/lib/server/log-safe.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Safe error logging for server-side code.
|
||||
*
|
||||
* `console.error('...', err)` runs the error through Node's util.inspect, which
|
||||
* walks the whole object graph. For an axios error that graph includes
|
||||
* `config.headers.Authorization` (the caller's JWT) and `config.data` (the
|
||||
* request body, which for the token-refresh call IS the refresh token, and for
|
||||
* the OAuth exchange is the authorization code plus the PKCE verifier). Those
|
||||
* end up in persistent server logs.
|
||||
*
|
||||
* Nothing here is derived from the request: no headers, no body, no URL. Only
|
||||
* the message, HTTP status and error code, which together with the caller's own
|
||||
* label are enough to identify what failed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reduce an error to a single line safe to write to server logs.
|
||||
*
|
||||
* @param {unknown} err - Any thrown value.
|
||||
* @returns {string} A compact description containing no credentials.
|
||||
*/
|
||||
export function describeError(err) {
|
||||
if (err === null || err === undefined) return 'unknown error';
|
||||
|
||||
const message =
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
typeof (/** @type {any} */ (err).message) === 'string'
|
||||
? /** @type {any} */ (err).message
|
||||
: String(err);
|
||||
|
||||
const parts = [message];
|
||||
|
||||
const status = /** @type {any} */ (err)?.response?.status ?? /** @type {any} */ (err)?.status;
|
||||
if (status !== undefined && status !== null) parts.push(`status=${status}`);
|
||||
|
||||
const code = /** @type {any} */ (err)?.code;
|
||||
if (typeof code === 'string' && code) parts.push(`code=${code}`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
94
frontend/src/lib/server/packs.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Vertical packs: the pack chooser shown at org creation.
|
||||
*
|
||||
* Server-only, matching every other `#lib/server/*` module: the JWT cookie
|
||||
* never leaves the server. `apiRequest` lives at `#lib/api-helpers.js` (NOT
|
||||
* `#lib/server/api`: that module does not exist) and its `API_BASE_URL`
|
||||
* already includes `/api`, so endpoints here are given WITHOUT the `/api`
|
||||
* prefix, same as every neighbouring module (see `#lib/server/v2/tags.js`,
|
||||
* `#lib/server/v2/organization.js`).
|
||||
*
|
||||
* GET /api/packs/ needs IsAuthenticated only, no org context. It is exempt
|
||||
* from HasOrgContext / RequireOrgContext (see `PackListView` and
|
||||
* `EXEMPT_EXACT_PATHS` in `common/middleware/rls_context.py`) precisely so a
|
||||
* brand-new user with zero orgs, who has no `org_id` claim on their JWT yet,
|
||||
* can still list packs on `/org/new`, the "first ever org" page. Callers must
|
||||
* still treat a failed listPacks() as "no packs available" rather than
|
||||
* letting the error propagate and break the page. This call can still fail
|
||||
* for other reasons (network, auth token expiry, etc).
|
||||
*/
|
||||
import { apiRequest } from '#lib/api-helpers.js';
|
||||
|
||||
/**
|
||||
* The minimal shape `apiRequest` actually needs: something with a `.get()`
|
||||
* that returns the access token. A real SvelteKit `Cookies` satisfies this
|
||||
* structurally, but so does a throwaway bearer-only reader that is never
|
||||
* connected to the response's Set-Cookie headers. See `applyPack`'s use in
|
||||
* `org/new/+page.server.js`, where callers deliberately pass one instead of
|
||||
* the real cookie jar so an org-scoped token minted mid-request never gets
|
||||
* persisted to the caller's session.
|
||||
* @typedef {{ get: (name: string) => string | undefined }} CookieReader
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {import('@sveltejs/kit').Cookies | CookieReader} cookies
|
||||
* @returns {Promise<Array<{ id: string, name: string, description: string, version: string }>>}
|
||||
*/
|
||||
export async function listPacks(cookies) {
|
||||
// apiRequest's own type only accepts a real Cookies here; at runtime it
|
||||
// only ever calls `.get()`, which both a real Cookies and a CookieReader
|
||||
// provide, so this cast is safe.
|
||||
const res = await apiRequest(
|
||||
'/packs/',
|
||||
{ method: 'GET' },
|
||||
{ cookies: /** @type {import('@sveltejs/kit').Cookies} */ (cookies) }
|
||||
);
|
||||
return res?.packs ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/packs/<id>/apply/, ADMIN only, org derived server-side from
|
||||
* `request.profile.org`. Caller must supply cookies that already carry a
|
||||
* token scoped to the org the pack should apply to.
|
||||
*
|
||||
* @param {import('@sveltejs/kit').Cookies | CookieReader} cookies
|
||||
* @param {string} packId
|
||||
* @returns {Promise<{ report: { created: any[], skipped: any[], failed: any[] } }>}
|
||||
*/
|
||||
export async function applyPack(cookies, packId) {
|
||||
return apiRequest(
|
||||
`/packs/${encodeURIComponent(packId)}/apply/`,
|
||||
{ method: 'POST' },
|
||||
{ cookies: /** @type {import('@sveltejs/kit').Cookies} */ (cookies) }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/packs/sample-data/: ADMIN only, org derived server-side.
|
||||
* Deletes the demo records this org's applier created: accounts, contacts,
|
||||
* deals, tickets, tasks and leads carrying `is_sample`, and nothing else. See
|
||||
* `backend/common/packs/applier.py:clear_sample_data`. Real records, and
|
||||
* another org's sample records, are untouched.
|
||||
*
|
||||
* `retained_by_type` is a normal outcome rather than a failure: a demo record
|
||||
* the user has since attached their own work to (a real deal on a sample
|
||||
* account, billable time on a sample ticket, an invoice raised against a sample
|
||||
* account) is deliberately kept, because deleting it would take that work with
|
||||
* it, or, for the invoice case, fail outright on a PROTECT constraint. Surface
|
||||
* it to the user; do not treat it as an error.
|
||||
*
|
||||
* There is no separate endpoint to preview this count before deleting.
|
||||
* The applier module exposes exactly list/apply/clear, no dry-run. A caller
|
||||
* wanting to confirm "how many?" ahead of time has nothing authoritative to
|
||||
* ask; the count only exists once this call has already acted.
|
||||
*
|
||||
* @param {import('@sveltejs/kit').Cookies | CookieReader} cookies
|
||||
* @returns {Promise<{ deleted: number, deleted_by_type: Record<string, number>, retained_by_type: Record<string, number> }>}
|
||||
*/
|
||||
export async function clearSampleData(cookies) {
|
||||
return apiRequest(
|
||||
'/packs/sample-data/',
|
||||
{ method: 'DELETE' },
|
||||
{ cookies: /** @type {import('@sveltejs/kit').Cookies} */ (cookies) }
|
||||
);
|
||||
}
|
||||
137
frontend/src/lib/server/portal.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Server-side calls to /api/portal/, the customer self-service portal.
|
||||
*
|
||||
* The session cookie is `portal_access`, deliberately NOT `jwt_access`.
|
||||
* `hooks.server.js` reads `jwt_access`, `jwt_refresh` and `org` to decide
|
||||
* whether somebody is a signed-in staff user, so reusing those names would hand
|
||||
* a customer the application shell. `portal_org` rides alongside it purely so an
|
||||
* expired session knows which org's sign-in page to return to; it is an id that
|
||||
* already appears in the URL, not a credential.
|
||||
*
|
||||
* There is no refresh cookie. Portal tokens last 24 hours and the customer asks
|
||||
* for a new sign-in code after that, which keeps token rotation out of the
|
||||
* surface entirely. See the design note in `common/portal_auth.py`.
|
||||
*/
|
||||
|
||||
import * as env from '$app/env/public';
|
||||
import { relayHeaders } from '#lib/server/relay.js';
|
||||
|
||||
const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api/portal`;
|
||||
|
||||
export const ACCESS_COOKIE = 'portal_access';
|
||||
export const ORG_COOKIE = 'portal_org';
|
||||
|
||||
// Matches the 24 hour token lifetime in common/portal_auth.py. A cookie that
|
||||
// outlived the token would leave the customer on a page that 401s instead of
|
||||
// sending them to sign in again.
|
||||
export const SESSION_MAX_AGE = 60 * 60 * 24;
|
||||
|
||||
// The org id deliberately outlives the session. It is not a credential (it is
|
||||
// already in the URL of every email this org sends), and it is the only way an
|
||||
// expired visitor can be returned to *their* sign-in page rather than to the
|
||||
// staff login, which is a confusing dead end for a customer.
|
||||
export const ORG_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
/** Thrown when the portal API answers with a non-2xx status. */
|
||||
export class PortalError extends Error {
|
||||
constructor(status, data) {
|
||||
super(`portal request failed with ${status}`);
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {{ method?: string, token?: string, body?: unknown, headers?: Record<string, string> }} [options]
|
||||
*/
|
||||
async function call(path, { method = 'GET', token, body, headers = {} } = {}) {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...headers
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!response.ok) throw new PortalError(response.status, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sign-in token records who asked for it, so the visitor's signed address
|
||||
* goes along (`#lib/server/relay.js`).
|
||||
*
|
||||
* @param {string} org
|
||||
* @param {string} email
|
||||
* @param {{ getClientAddress: () => string, request: Request }} event
|
||||
*/
|
||||
export const requestLogin = (org, email, event) =>
|
||||
call(`/login/${org}/request/`, {
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
headers: relayHeaders(event)
|
||||
});
|
||||
|
||||
export const verifyLogin = (org, email, code) =>
|
||||
call(`/login/${org}/verify/`, { method: 'POST', body: { email, code } });
|
||||
|
||||
export const listCases = (token, status) =>
|
||||
call(`/cases/${status ? `?status=${encodeURIComponent(status)}` : ''}`, { token });
|
||||
|
||||
export const getCase = (token, id) => call(`/cases/${id}/`, { token });
|
||||
|
||||
export const createCase = (token, body) => call('/cases/', { method: 'POST', token, body });
|
||||
|
||||
export const postReply = (token, id, comment) =>
|
||||
call(`/cases/${id}/comment/`, { method: 'POST', token, body: { comment } });
|
||||
|
||||
export const listArticles = (token, search) =>
|
||||
call(`/articles/${search ? `?search=${encodeURIComponent(search)}` : ''}`, { token });
|
||||
|
||||
export const getArticle = (token, id) => call(`/articles/${id}/`, { token });
|
||||
|
||||
export const suggestArticles = (token, q) =>
|
||||
call(`/articles/suggest/?q=${encodeURIComponent(q)}`, { token });
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Where to send a visitor with no usable session.
|
||||
*
|
||||
* `/login` is the staff sign-in, which a customer cannot use, so it is the last
|
||||
* resort and not the default. The org is checked only for being a UUID, which
|
||||
* is all that is needed to build a path we control; it grants nothing, so a
|
||||
* wrong one costs a visitor a sign-in page that will never email them.
|
||||
*
|
||||
* @param {string | null | undefined} org
|
||||
*/
|
||||
export const loginPath = (org) => (org && UUID.test(org) ? `/portal/login/${org}` : '/login');
|
||||
|
||||
/** Write the session, httpOnly so no script on the page can read the token. */
|
||||
export function setSession(cookies, accessToken, orgId) {
|
||||
const options = {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: SESSION_MAX_AGE
|
||||
};
|
||||
cookies.set(ACCESS_COOKIE, accessToken, options);
|
||||
cookies.set(ORG_COOKIE, orgId, { ...options, httpOnly: false, maxAge: ORG_MAX_AGE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the credential, keep the signpost.
|
||||
*
|
||||
* `portal_org` survives on purpose so the next visit lands on the right org's
|
||||
* sign-in page. Clearing it too would send a returning customer to the staff
|
||||
* login, which they cannot use.
|
||||
*/
|
||||
export function clearSession(cookies) {
|
||||
cookies.delete(ACCESS_COOKIE, { path: '/' });
|
||||
}
|
||||
219
frontend/src/lib/server/relay-callers.test.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* Every server-side call to an anonymous API endpoint that records the
|
||||
* caller's address sends the signed visitor address (`relayHeaders`), and
|
||||
* nothing else about the visitor's own headers.
|
||||
*
|
||||
* Route modules are imported by path: these import nothing the harness
|
||||
* cannot resolve (see vitest.config.js). Token refresh and the shell's own org
|
||||
* switch live in hooks.server.js and are tested in hooks.server.test.js.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('axios', () => ({ default: { post: vi.fn(), get: vi.fn() } }));
|
||||
vi.mock('#lib/server/packs.js', () => ({ listPacks: vi.fn(), applyPack: vi.fn() }));
|
||||
|
||||
const axios = (await import('axios')).default;
|
||||
// The stub that vitest.config.js aliases `$app/env/private` to, so this is the module the code
|
||||
// under test reads. Imported by path because only the stub has a setter.
|
||||
const { setRelaySecret } = await import('../../../test/stubs/app-env-private.js');
|
||||
const { requestLogin, verifyLogin } = await import('#lib/server/portal.js');
|
||||
const login = await import('../../routes/(no-layout)/login/+page.server.js');
|
||||
const verify = await import('../../routes/(no-layout)/login/verify/+page.server.js');
|
||||
const logout = await import('../../routes/(no-layout)/logout/+page.server.js');
|
||||
const selectOrg = await import('../../routes/(no-layout)/org/+page.server.js');
|
||||
const newOrg = await import('../../routes/(no-layout)/org/new/+page.server.js');
|
||||
|
||||
const SECRET = 'r'.repeat(48);
|
||||
const VISITOR = '198.51.100.7';
|
||||
const BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/142.0 Safari/537.36';
|
||||
const SIGNED = {
|
||||
'X-Forwarded-For': VISITOR,
|
||||
'X-BottleCRM-Relay-Secret': SECRET,
|
||||
'X-BottleCRM-Client-IP': VISITOR,
|
||||
'X-BottleCRM-User-Agent': BROWSER
|
||||
};
|
||||
const getClientAddress = () => VISITOR;
|
||||
/** The visitor's own request, as SvelteKit hands it to a load or action. */
|
||||
const visit = (url = 'http://app.test/', init = {}) =>
|
||||
new Request(url, { ...init, headers: { 'user-agent': BROWSER } });
|
||||
|
||||
const cookies = () => ({ get: vi.fn(() => 'refresh'), set: vi.fn(), delete: vi.fn() });
|
||||
|
||||
/** @param {() => unknown} run */
|
||||
async function ignoringRedirect(run) {
|
||||
try {
|
||||
await run();
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (!err?.status || err.status < 300 || err.status >= 400) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
setRelaySecret(SECRET);
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { access_token: 'a', refresh_token: 'r' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
setRelaySecret(undefined);
|
||||
});
|
||||
|
||||
describe('magic-link request (records the address on the token)', () => {
|
||||
const submit = () => {
|
||||
const body = new FormData();
|
||||
body.set('email', 'ada@example.com');
|
||||
const request = visit('http://app.test/login', { method: 'POST', body });
|
||||
return login.actions.default(/** @type {any} */ ({ request, getClientAddress }));
|
||||
};
|
||||
|
||||
it('sends the signed visitor address', async () => {
|
||||
await submit();
|
||||
const [url, , config] = vi.mocked(axios.post).mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/auth\/magic-link\/request\/$/);
|
||||
expect(config?.headers).toMatchObject(SIGNED);
|
||||
});
|
||||
|
||||
it('sends only the unsigned, pre-1.12 address without a secret', async () => {
|
||||
setRelaySecret(undefined);
|
||||
await submit();
|
||||
expect(vi.mocked(axios.post).mock.calls[0][2]?.headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forwarded-For': VISITOR
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('magic-link verify (the sign-in audit row records the address)', () => {
|
||||
it('sends the signed visitor address', async () => {
|
||||
const link = new URL('http://app.test/login/verify?token=t');
|
||||
await ignoringRedirect(() =>
|
||||
verify.actions.default(
|
||||
/** @type {any} */ ({
|
||||
url: link,
|
||||
request: visit(link.href, { method: 'POST', body: new FormData() }),
|
||||
cookies: cookies(),
|
||||
getClientAddress
|
||||
})
|
||||
)
|
||||
);
|
||||
const [url, , config] = vi.mocked(axios.post).mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/auth\/magic-link\/verify\/$/);
|
||||
expect(config?.headers).toMatchObject(SIGNED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google sign-in (the audit row, and the per-address cap on failure rows)', () => {
|
||||
it('sends the signed visitor address', async () => {
|
||||
const jar = {
|
||||
get: vi.fn((/** @type {string} */ name) => (name === 'oauth_state' ? 's' : 'verifier')),
|
||||
set: vi.fn(),
|
||||
delete: vi.fn()
|
||||
};
|
||||
await ignoringRedirect(() =>
|
||||
login.load(
|
||||
/** @type {any} */ ({
|
||||
url: new URL('http://app.test/login?code=c&state=s'),
|
||||
request: visit('http://app.test/login?code=c&state=s'),
|
||||
cookies: jar,
|
||||
getClientAddress
|
||||
})
|
||||
)
|
||||
);
|
||||
const [url, , config] = vi.mocked(axios.post).mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/auth\/google\/callback\/$/);
|
||||
expect(config?.headers).toMatchObject(SIGNED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout (the audit row records the address)', () => {
|
||||
it('sends the signed visitor address', async () => {
|
||||
await ignoringRedirect(() =>
|
||||
logout.load(
|
||||
/** @type {any} */ ({
|
||||
locals: {},
|
||||
cookies: cookies(),
|
||||
fetch: fetchMock,
|
||||
getClientAddress,
|
||||
request: visit('http://app.test/logout')
|
||||
})
|
||||
)
|
||||
);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/auth\/logout\/$/);
|
||||
expect(init.headers).toMatchObject(SIGNED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('portal sign-in (records the address on the token)', () => {
|
||||
it('sends the signed visitor address with the request', async () => {
|
||||
await requestLogin('org-1', 'ada@example.com', { getClientAddress, request: visit() });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/portal\/login\/org-1\/request\/$/);
|
||||
expect(init.headers).toMatchObject(SIGNED);
|
||||
});
|
||||
|
||||
it('sends only the unsigned, pre-1.12 address without a secret', async () => {
|
||||
setRelaySecret(undefined);
|
||||
await requestLogin('org-1', 'ada@example.com', { getClientAddress, request: visit() });
|
||||
expect(fetchMock.mock.calls[0][1].headers).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forwarded-For': VISITOR
|
||||
});
|
||||
});
|
||||
|
||||
it('does not add it where nothing records it (verify)', async () => {
|
||||
await verifyLogin('org-1', 'ada@example.com', '123456');
|
||||
expect(fetchMock.mock.calls[0][1].headers).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('org switch (the audit row records the address)', () => {
|
||||
const ORG = '11111111-2222-3333-4444-555555555555';
|
||||
/** @param {Record<string, string>} fields */
|
||||
const form = (fields) => {
|
||||
const body = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) body.set(k, v);
|
||||
return visit('http://app.test/org', { method: 'POST', body });
|
||||
};
|
||||
/** @param {string} path */
|
||||
const callTo = (path) =>
|
||||
vi.mocked(axios.post).mock.calls.find(([url]) => String(url).endsWith(path));
|
||||
|
||||
it('sends the signed visitor address when picking an org', async () => {
|
||||
await ignoringRedirect(() =>
|
||||
selectOrg.actions.selectOrg(
|
||||
/** @type {any} */ ({
|
||||
request: form({ org_id: ORG }),
|
||||
cookies: cookies(),
|
||||
getClientAddress
|
||||
})
|
||||
)
|
||||
);
|
||||
const [, , config] = /** @type {any[]} */ (callTo('/api/auth/switch-org/'));
|
||||
expect(config.headers).toMatchObject(SIGNED);
|
||||
});
|
||||
|
||||
it('sends it on the switch that applies a pack to a new org', async () => {
|
||||
vi.mocked(axios.post)
|
||||
.mockResolvedValueOnce({ data: { org: { id: ORG } } })
|
||||
.mockResolvedValueOnce({ data: { access_token: 'a' } });
|
||||
await ignoringRedirect(() =>
|
||||
newOrg.actions.default(
|
||||
/** @type {any} */ ({
|
||||
request: form({ org_name: 'Acme', vertical: 'agency' }),
|
||||
cookies: cookies(),
|
||||
locals: { user: { id: 'u' } },
|
||||
getClientAddress
|
||||
})
|
||||
)
|
||||
);
|
||||
const [, , config] = /** @type {any[]} */ (callTo('/api/auth/switch-org/'));
|
||||
expect(config.headers).toMatchObject(SIGNED);
|
||||
});
|
||||
});
|
||||
54
frontend/src/lib/server/relay.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Headers that tell the API which visitor this server is relaying for: their
|
||||
* address, and their browser's User-Agent (this server's own is its HTTP
|
||||
* client, `axios` or `node`, which is what every relayed sign-in recorded
|
||||
* before). The API reads `X-BottleCRM-User-Agent` only from a request that
|
||||
* also carries the secret (`common.request_meta.user_agent`), so it is sent
|
||||
* with the signed pair.
|
||||
*
|
||||
* Anonymous pages that this server fetches on a visitor's behalf (the help
|
||||
* center, estimate acceptance, sign-in and sign-out, token refresh and org
|
||||
* switch) reach the API from this server's address, so without these every
|
||||
* visitor shares one per-IP throttle bucket and one recorded address. The API
|
||||
* believes `X-BottleCRM-Client-IP` only alongside `X-BottleCRM-Relay-Secret`
|
||||
* matching its own `RELAY_SECRET` (`common.request_meta.client_ip`), so that
|
||||
* pair is sent only when `RELAY_SECRET` is set.
|
||||
*
|
||||
* `X-Forwarded-For` carries the same visitor address, never the incoming
|
||||
* header (the visitor writes that). It is compatibility with APIs before
|
||||
* django-crm 1.12.0, which read the first `X-Forwarded-For` entry, so this
|
||||
* server can deploy before the API does without collapsing every visitor into
|
||||
* one bucket. 1.12.0 ignores it unless `NUM_PROXIES` is set.
|
||||
*
|
||||
* `getClientAddress()` is the visitor only when adapter-node is told which
|
||||
* header nginx writes: run it with `ADDRESS_HEADER=X-Forwarded-For` and
|
||||
* `XFF_DEPTH=1`, bound to `HOST=127.0.0.1` so nothing can reach it except
|
||||
* through nginx. Without them it is nginx's own loopback address.
|
||||
*/
|
||||
|
||||
import * as env from '$app/env/private';
|
||||
|
||||
/**
|
||||
* @param {{ getClientAddress: () => string, request: Request }} event
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function relayHeaders(event) {
|
||||
let address;
|
||||
try {
|
||||
address = event.getClientAddress();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!address) return {};
|
||||
const secret = env.RELAY_SECRET;
|
||||
return {
|
||||
'X-Forwarded-For': address,
|
||||
...(secret
|
||||
? {
|
||||
'X-BottleCRM-Relay-Secret': secret,
|
||||
'X-BottleCRM-Client-IP': address,
|
||||
'X-BottleCRM-User-Agent': event.request.headers.get('user-agent') ?? ''
|
||||
}
|
||||
: {})
|
||||
};
|
||||
}
|
||||
71
frontend/src/lib/server/relay.test.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const { relayHeaders } = await import('#lib/server/relay.js');
|
||||
// The stub that vitest.config.js aliases `$app/env/private` to, so this is the module the code
|
||||
// under test reads. Imported by path because only the stub has a setter.
|
||||
const { setRelaySecret } = await import('../../../test/stubs/app-env-private.js');
|
||||
|
||||
const SECRET = 'r'.repeat(48);
|
||||
|
||||
const BROWSER = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/142.0 Safari/537.36';
|
||||
|
||||
/**
|
||||
* @param {() => string} getClientAddress
|
||||
* @param {Record<string, string>} [headers] the visitor's own request headers
|
||||
*/
|
||||
const event = (getClientAddress, headers = { 'user-agent': BROWSER }) => ({
|
||||
getClientAddress,
|
||||
request: new Request('http://app.test/', { headers })
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setRelaySecret(undefined);
|
||||
});
|
||||
|
||||
describe('relayHeaders', () => {
|
||||
it('names the visitor and their browser, signed, when a secret is set', () => {
|
||||
setRelaySecret(SECRET);
|
||||
expect(relayHeaders(event(() => '198.51.100.7'))).toEqual({
|
||||
'X-Forwarded-For': '198.51.100.7',
|
||||
'X-BottleCRM-Relay-Secret': SECRET,
|
||||
'X-BottleCRM-Client-IP': '198.51.100.7',
|
||||
'X-BottleCRM-User-Agent': BROWSER
|
||||
});
|
||||
});
|
||||
|
||||
it('names an empty browser when the visitor sent no User-Agent', () => {
|
||||
setRelaySecret(SECRET);
|
||||
expect(relayHeaders(event(() => '198.51.100.7', {}))).toMatchObject({
|
||||
'X-BottleCRM-User-Agent': ''
|
||||
});
|
||||
});
|
||||
|
||||
it('sends only the pre-1.12 X-Forwarded-For when the secret is unset', () => {
|
||||
expect(relayHeaders(event(() => '198.51.100.7'))).toEqual({
|
||||
'X-Forwarded-For': '198.51.100.7'
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an empty secret as unset', () => {
|
||||
setRelaySecret('');
|
||||
expect(relayHeaders(event(() => '198.51.100.7'))).toEqual({
|
||||
'X-Forwarded-For': '198.51.100.7'
|
||||
});
|
||||
});
|
||||
|
||||
it('sends nothing when the address cannot be read', () => {
|
||||
setRelaySecret(SECRET);
|
||||
expect(
|
||||
relayHeaders(
|
||||
event(() => {
|
||||
throw new Error('no address');
|
||||
})
|
||||
)
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('sends nothing when the address is empty', () => {
|
||||
setRelaySecret(SECRET);
|
||||
expect(relayHeaders(event(() => ''))).toEqual({});
|
||||
});
|
||||
});
|
||||
137
frontend/src/lib/server/signup-source-routes.test.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* The sign-in page remembers where a visitor came from, and creating an org
|
||||
* sends it once. Route modules are imported by path, as in
|
||||
* relay-callers.test.js.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SIGNUP_SOURCE_COOKIE } from './signup-source.js';
|
||||
|
||||
vi.mock('axios', () => ({ default: { post: vi.fn(), get: vi.fn() } }));
|
||||
vi.mock('#lib/server/packs.js', () => ({ listPacks: vi.fn(), applyPack: vi.fn() }));
|
||||
|
||||
const axios = (await import('axios')).default;
|
||||
const login = await import('../../routes/(no-layout)/login/+page.server.js');
|
||||
const newOrg = await import('../../routes/(no-layout)/org/new/+page.server.js');
|
||||
|
||||
/** @param {Record<string, string>} initial */
|
||||
function jar(initial = {}) {
|
||||
/** @type {Record<string, string>} */
|
||||
const store = { ...initial };
|
||||
return {
|
||||
store,
|
||||
get: vi.fn((/** @type {string} */ name) => store[name]),
|
||||
set: vi.fn((/** @type {string} */ name, /** @type {string} */ value) => {
|
||||
store[name] = value;
|
||||
}),
|
||||
delete: vi.fn((/** @type {string} */ name) => {
|
||||
delete store[name];
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {() => unknown} run */
|
||||
async function ignoringRedirect(run) {
|
||||
try {
|
||||
await run();
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (!err?.status || err.status < 300 || err.status >= 400) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} url @param {Record<string, string>} headers */
|
||||
function loadLogin(url, headers, cookies) {
|
||||
return ignoringRedirect(() =>
|
||||
login.load(
|
||||
/** @type {any} */ ({
|
||||
url: new URL(url),
|
||||
request: new Request(url, { headers }),
|
||||
cookies,
|
||||
getClientAddress: () => '198.51.100.7'
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function createOrg(cookies) {
|
||||
const body = new FormData();
|
||||
body.set('org_name', 'Acme');
|
||||
return ignoringRedirect(() =>
|
||||
newOrg.actions.default(
|
||||
/** @type {any} */ ({
|
||||
request: new Request('http://app.test/org/new', { method: 'POST', body }),
|
||||
cookies,
|
||||
locals: { user: { id: 'u' } },
|
||||
getClientAddress: () => '198.51.100.7'
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgCreateBody = () =>
|
||||
vi.mocked(axios.post).mock.calls.find(([url]) => String(url).endsWith('/api/org/'))?.[1];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(axios.post).mockReset();
|
||||
vi.mocked(axios.post).mockResolvedValue({ data: { org: { id: 'org-1' } } });
|
||||
});
|
||||
|
||||
describe('the sign-in page', () => {
|
||||
it('remembers the tags and the referring host of a first visit', async () => {
|
||||
const cookies = jar();
|
||||
await loadLogin(
|
||||
'http://app.test/login?utm_source=site&utm_campaign=pricing',
|
||||
{
|
||||
referer: 'https://www.example.org/pricing'
|
||||
},
|
||||
cookies
|
||||
);
|
||||
expect(JSON.parse(cookies.store[SIGNUP_SOURCE_COOKIE])).toEqual({
|
||||
utm_source: 'site',
|
||||
utm_campaign: 'pricing',
|
||||
referrer: 'www.example.org'
|
||||
});
|
||||
});
|
||||
|
||||
it("does not take Google's redirect back for a source", async () => {
|
||||
const cookies = jar();
|
||||
await loadLogin(
|
||||
'http://app.test/login?code=c&state=s',
|
||||
{
|
||||
referer: 'https://accounts.google.com/'
|
||||
},
|
||||
cookies
|
||||
);
|
||||
expect(cookies.store[SIGNUP_SOURCE_COOKIE]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('creating an org', () => {
|
||||
it('sends the remembered source once, then forgets it', async () => {
|
||||
const cookies = jar({
|
||||
jwt_access: 'token',
|
||||
[SIGNUP_SOURCE_COOKIE]: '{"utm_source":"site","referrer":"example.org"}'
|
||||
});
|
||||
await createOrg(cookies);
|
||||
expect(orgCreateBody()).toEqual({
|
||||
name: 'Acme',
|
||||
signup_source: { utm_source: 'site', referrer: 'example.org' }
|
||||
});
|
||||
expect(cookies.store[SIGNUP_SOURCE_COOKIE]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends no source when none was remembered', async () => {
|
||||
await createOrg(jar({ jwt_access: 'token' }));
|
||||
expect(orgCreateBody()).toEqual({ name: 'Acme' });
|
||||
});
|
||||
|
||||
it('keeps the source when the create is refused', async () => {
|
||||
vi.mocked(axios.post).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Bad Request'), {
|
||||
response: { status: 400, data: { errors: { name: ['taken'] } } }
|
||||
})
|
||||
);
|
||||
const cookies = jar({ jwt_access: 'token', [SIGNUP_SOURCE_COOKIE]: '{"utm_source":"site"}' });
|
||||
await createOrg(cookies);
|
||||
expect(cookies.store[SIGNUP_SOURCE_COOKIE]).toBe('{"utm_source":"site"}');
|
||||
});
|
||||
});
|
||||
70
frontend/src/lib/server/signup-source.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Where a new org came from: the first visit's campaign tags and the host that
|
||||
* referred it, kept in a cookie until the org is created, then sent with the
|
||||
* create. The API cleans the value again; nothing here is a check.
|
||||
*/
|
||||
|
||||
export const SIGNUP_SOURCE_COOKIE = 'signup_source';
|
||||
|
||||
const TAGS = ['utm_source', 'utm_medium', 'utm_campaign'];
|
||||
const MAX_LENGTH = 100;
|
||||
const THIRTY_DAYS = 60 * 60 * 24 * 30;
|
||||
|
||||
/**
|
||||
* The tags on this URL, and the referring host unless it is this app's own.
|
||||
* Only the host of a referrer is kept: its path or query can carry tokens.
|
||||
* @param {URL} url
|
||||
* @param {string | null} referer
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function sourceOfVisit(url, referer) {
|
||||
/** @type {Record<string, string>} */
|
||||
const source = {};
|
||||
for (const tag of TAGS) {
|
||||
const value = url.searchParams.get(tag)?.trim();
|
||||
if (value) source[tag] = value.slice(0, MAX_LENGTH);
|
||||
}
|
||||
if (referer) {
|
||||
try {
|
||||
const host = new URL(referer).hostname.toLowerCase();
|
||||
if (host && host !== url.hostname.toLowerCase()) source.referrer = host;
|
||||
} catch {
|
||||
// A malformed header names no source.
|
||||
}
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember this visit's source unless an earlier visit already did: the first
|
||||
* visit is the one that brought the person here.
|
||||
* @param {{ url: URL, request: Request, cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {boolean} secure
|
||||
*/
|
||||
export function rememberSignupSource({ url, request, cookies }, secure) {
|
||||
if (cookies.get(SIGNUP_SOURCE_COOKIE) !== undefined) return;
|
||||
const source = sourceOfVisit(url, request.headers.get('referer'));
|
||||
cookies.set(SIGNUP_SOURCE_COOKIE, JSON.stringify(source), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
maxAge: THIRTY_DAYS
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The remembered source, or undefined when there is none or it will not parse.
|
||||
* @param {{ get: (name: string) => string | undefined }} cookies
|
||||
* @returns {Record<string, unknown> | undefined}
|
||||
*/
|
||||
export function readSignupSource(cookies) {
|
||||
const raw = cookies.get(SIGNUP_SOURCE_COOKIE);
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
110
frontend/src/lib/server/signup-source.test.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SIGNUP_SOURCE_COOKIE,
|
||||
readSignupSource,
|
||||
rememberSignupSource,
|
||||
sourceOfVisit
|
||||
} from './signup-source.js';
|
||||
|
||||
const APP = 'https://crm.example.com/login';
|
||||
|
||||
function fakeCookies(initial = {}) {
|
||||
/** @type {Record<string, string>} */
|
||||
const jar = { ...initial };
|
||||
/** @type {any[]} */
|
||||
const sets = [];
|
||||
return {
|
||||
jar,
|
||||
sets,
|
||||
get: (/** @type {string} */ name) => jar[name],
|
||||
set: (/** @type {string} */ name, /** @type {string} */ value, /** @type {any} */ opts) => {
|
||||
jar[name] = value;
|
||||
sets.push({ name, value, opts });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {string} url @param {string | null} referer */
|
||||
function visit(url, referer = null) {
|
||||
const headers = new Headers();
|
||||
if (referer) headers.set('referer', referer);
|
||||
return { url: new URL(url), request: new Request(url, { headers }) };
|
||||
}
|
||||
|
||||
describe('sourceOfVisit', () => {
|
||||
it('keeps the campaign tags and the referring host', () => {
|
||||
const url = new URL(`${APP}?utm_source=site&utm_medium=web&utm_campaign=pricing&x=1`);
|
||||
expect(sourceOfVisit(url, 'https://News.Example.org/some/path?token=abc')).toEqual({
|
||||
utm_source: 'site',
|
||||
utm_medium: 'web',
|
||||
utm_campaign: 'pricing',
|
||||
referrer: 'news.example.org'
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the app's own pages as a referrer", () => {
|
||||
expect(sourceOfVisit(new URL(APP), 'https://crm.example.com/logout')).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores a malformed referrer and blank tags', () => {
|
||||
expect(sourceOfVisit(new URL(`${APP}?utm_source=%20`), 'not a url')).toEqual({});
|
||||
});
|
||||
|
||||
it('cuts a long tag', () => {
|
||||
const url = new URL(`${APP}?utm_campaign=${'c'.repeat(300)}`);
|
||||
expect(sourceOfVisit(url, null).utm_campaign).toHaveLength(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rememberSignupSource', () => {
|
||||
it('stores the first visit in an httpOnly cookie', () => {
|
||||
const cookies = fakeCookies();
|
||||
rememberSignupSource(
|
||||
{
|
||||
...visit(`${APP}?utm_source=site`, 'https://example.org/'),
|
||||
cookies: /** @type {any} */ (cookies)
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(JSON.parse(cookies.jar[SIGNUP_SOURCE_COOKIE])).toEqual({
|
||||
utm_source: 'site',
|
||||
referrer: 'example.org'
|
||||
});
|
||||
expect(cookies.sets[0].opts).toMatchObject({
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
path: '/'
|
||||
});
|
||||
});
|
||||
|
||||
it('records a direct visit too, so a later one does not replace it', () => {
|
||||
const cookies = fakeCookies();
|
||||
rememberSignupSource({ ...visit(APP), cookies: /** @type {any} */ (cookies) }, false);
|
||||
expect(cookies.jar[SIGNUP_SOURCE_COOKIE]).toBe('{}');
|
||||
});
|
||||
|
||||
it('keeps an earlier visit', () => {
|
||||
const cookies = fakeCookies({ [SIGNUP_SOURCE_COOKIE]: '{"referrer":"first.example"}' });
|
||||
rememberSignupSource(
|
||||
{ ...visit(`${APP}?utm_source=later`), cookies: /** @type {any} */ (cookies) },
|
||||
false
|
||||
);
|
||||
expect(cookies.sets).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readSignupSource', () => {
|
||||
it('returns the stored object', () => {
|
||||
const cookies = fakeCookies({ [SIGNUP_SOURCE_COOKIE]: '{"utm_source":"site"}' });
|
||||
expect(readSignupSource(cookies)).toEqual({ utm_source: 'site' });
|
||||
});
|
||||
|
||||
it.each([undefined, '', 'not json', '[1,2]', '"text"', 'null'])(
|
||||
'returns undefined for %j',
|
||||
(raw) => {
|
||||
const cookies = fakeCookies(raw === undefined ? {} : { [SIGNUP_SOURCE_COOKIE]: raw });
|
||||
expect(readSignupSource(cookies)).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
536
frontend/src/lib/server/v2/accounts.js
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
/**
|
||||
* Accounts: the third v2 module wired to the real API.
|
||||
*
|
||||
* Same rules as `leads.js` and `deals.js`: this file lives under `#lib/server`
|
||||
* because SvelteKit refuses to bundle that directory into client code and the
|
||||
* access token is an httpOnly cookie. The org is never a parameter; it is a
|
||||
* claim inside the JWT and the backend reads it from there.
|
||||
*
|
||||
* WHY THIS MODULE, AND WHY NOW
|
||||
* `/pipeline` went live linking each deal to `/accounts/{uuid}`, and
|
||||
* `/accounts` was still fixtures keyed by slugs like `acc-northwind`. So
|
||||
* every account link on a live page answered 404. A module is not finished
|
||||
* when its own pages work; it is finished when the pages it points at do.
|
||||
*
|
||||
* WHAT CHANGED WHEN THE FIXTURES CAME OFF
|
||||
* - `renewal_note` ("Renews every August", "Prospect, no contract yet") is
|
||||
* gone. There is no contract, subscription or renewal model anywhere in the
|
||||
* backend, and nothing to derive one from. A renewal date is the kind of
|
||||
* invention somebody plans a quarter around.
|
||||
* - `customer_since` is gone as a stored fact and comes back as `first_won_on`
|
||||
*: the close date of the first deal won against this company, which the CRM
|
||||
* does know. The page still says "customer since"; the field name says where
|
||||
* the date came from.
|
||||
* - `billing_city` / `billing_country` are `city` / `country_display`. Account
|
||||
* has one flat address, not a billing and a shipping one.
|
||||
* - `employees` is `number_of_employees`.
|
||||
* - A contact's `relationship` ("Champion", "Blocker") is gone for the third
|
||||
* time; Contact has `title` and `department`.
|
||||
*
|
||||
* WHAT THE FIXTURE GOT RIGHT
|
||||
* The mock refused to store `lifetime_value` / `open_pipeline` /
|
||||
* `overdue_amount` and derived them from the records instead, with a comment
|
||||
* saying the real API had to aggregate them. It does now: the counts in
|
||||
* `accounts.views.annotate_rollups`, the money in `attach_money_rollups`.
|
||||
*
|
||||
* MONEY IS PER CURRENCY
|
||||
* Deals and invoices each carry a currency and there are no exchange rates, so
|
||||
* the API never adds two currencies together: the money arrives as
|
||||
* `rollups.by_currency`. It becomes `won_by_currency`, `pipeline_by_currency`
|
||||
* and `overdue_by_currency` here, each a list of `{currency, amount}` holding
|
||||
* only the currencies with something in them, so an empty list means nothing.
|
||||
*
|
||||
* NUMBERS ARRIVE AS STRINGS
|
||||
* DRF renders `DecimalField` as a string. The rollups come through the JSON
|
||||
* encoder as numbers, the model fields as strings, and the pages add them
|
||||
* together, so everything numeric is coerced here, at the boundary.
|
||||
*/
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { apiRequest } from '#lib/api-helpers.js';
|
||||
|
||||
/** DRF decimals are strings. `null` stays `null`. It means "not recorded". */
|
||||
function num(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice statuses where the money is still owed.
|
||||
*
|
||||
* Mirrors `invoices.models.UNPAID_STATUSES`, which the account rollup uses on
|
||||
* the server. It is repeated here so the per-row "past due" marker on the
|
||||
* invoice rail is decided by the same rule as the total printed above it.
|
||||
* The API's own `is_overdue` counts a *draft* past its date, which the header
|
||||
* deliberately does not, and a rail that disagrees with its own header is how
|
||||
* people stop believing either number.
|
||||
*/
|
||||
const UNPAID_STATUSES = ['Sent', 'Viewed', 'Partially_Paid', 'Overdue'];
|
||||
|
||||
/**
|
||||
* Whether this invoice is part of the figure in the header.
|
||||
*
|
||||
* @param {any} invoice
|
||||
*/
|
||||
function isPastDue(invoice) {
|
||||
if (!UNPAID_STATUSES.includes(invoice.status)) return false;
|
||||
if (!invoice.due_date) return false;
|
||||
if ((num(invoice.amount_due) ?? 0) <= 0) return false;
|
||||
return new Date(invoice.due_date) < new Date(new Date().toDateString());
|
||||
}
|
||||
|
||||
/**
|
||||
* `INDCHOICES` stores the label in the value, "FOOD & BEVERAGE", shouting.
|
||||
* Only the presentation changes; the value that round-trips to the API is
|
||||
* untouched.
|
||||
*
|
||||
* @param {string | null} industry
|
||||
*/
|
||||
function industryLabel(industry) {
|
||||
if (!industry) return '';
|
||||
return industry
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map((word) => (word === '&' ? '&' : word.charAt(0).toUpperCase() + word.slice(1)))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The aggregates, or nulls.
|
||||
*
|
||||
* `rollups` is null when the serializer was used somewhere that did not
|
||||
* annotate. Nulls travel through rather than becoming zeroes: "we did not
|
||||
* count" and "we counted nothing" are different sentences and the page prints
|
||||
* them differently.
|
||||
*
|
||||
* @param {any} rollups
|
||||
*/
|
||||
function toRollups(rollups) {
|
||||
if (!rollups) {
|
||||
return {
|
||||
won_by_currency: null,
|
||||
won_count: null,
|
||||
pipeline_by_currency: null,
|
||||
open_deal_count: null,
|
||||
overdue_by_currency: null,
|
||||
open_tickets: null,
|
||||
first_won_on: null
|
||||
};
|
||||
}
|
||||
/** @param {string} field */
|
||||
const perCurrency = (field) =>
|
||||
(rollups.by_currency ?? [])
|
||||
.map((/** @type {any} */ r) => ({ currency: r.currency, amount: num(r[field]) ?? 0 }))
|
||||
.filter((/** @type {any} */ m) => m.amount !== 0);
|
||||
return {
|
||||
won_by_currency: perCurrency('won_amount'),
|
||||
won_count: rollups.won_count ?? 0,
|
||||
pipeline_by_currency: perCurrency('open_pipeline'),
|
||||
open_deal_count: rollups.open_deal_count ?? 0,
|
||||
overdue_by_currency: perCurrency('overdue_amount'),
|
||||
open_tickets: rollups.open_tickets ?? 0,
|
||||
first_won_on: rollups.first_won_on ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of a Django account the v2 pages read.
|
||||
*
|
||||
* @param {any} account
|
||||
*/
|
||||
function toRow(account) {
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.name ?? '',
|
||||
industry: industryLabel(account.industry),
|
||||
industry_value: account.industry ?? '',
|
||||
website: account.website ?? '',
|
||||
email: account.email ?? '',
|
||||
phone: account.phone ?? '',
|
||||
number_of_employees: account.number_of_employees ?? null,
|
||||
annual_revenue: num(account.annual_revenue),
|
||||
currency: account.currency || 'USD',
|
||||
city: account.city ?? '',
|
||||
country: account.country ?? '',
|
||||
country_display: account.country_display ?? '',
|
||||
description: account.description ?? '',
|
||||
is_active: account.is_active !== false,
|
||||
created_at: account.created_at,
|
||||
...toRollups(account.rollups)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every filter param `listAccounts` will forward. See the note on
|
||||
* FILTER_FIELDS in tickets.js: the descriptor in `#lib/v2/filters.js` must not
|
||||
* name a key absent from this list, and `filters.test.js` enforces it.
|
||||
*
|
||||
* `industry` and `city` are `icontains` matches server-side, and `tags` and
|
||||
* `assigned_to` are `getlist` (`AccountsListView.get_context_data`,
|
||||
* `backend/accounts/views.py:213-226`), so more than one id can be sent for
|
||||
* either.
|
||||
*/
|
||||
export const FILTER_FIELDS = ['assigned_to', 'tags', 'industry', 'city'];
|
||||
|
||||
/**
|
||||
* Accounts in this org, largest won first (within each currency; see below).
|
||||
*
|
||||
* `active_accounts.open_accounts_count` is the size of the whole filtered
|
||||
* queryset; `open_accounts` is one page of it. `count` below reads the
|
||||
* former, so it does not under-report the way a header that counted only the
|
||||
* loaded page would.
|
||||
*
|
||||
* There is no `totals` envelope to prefer over this derivation here, unlike
|
||||
* `invoices.js` and `tasks.js`: `AccountsListView.get_context_data`
|
||||
* (`backend/accounts/views.py`) never puts a `totals` key on the response, so
|
||||
* a `response.totals ?? {...}` fallback would always fall through to the
|
||||
* `{...}` side, dead code pretending at a capability the API does not have.
|
||||
* `customers` is still computed from `rows`, the one page actually loaded
|
||||
* (`limit` defaults to 25), so it DOES under-report whenever the filtered set
|
||||
* is bigger than that page. Fixing that needs a real aggregate from the API,
|
||||
* which is not there to read yet.
|
||||
*
|
||||
* The API narrows this list for non-admins to accounts they created or are
|
||||
* assigned to, so what comes back is already what this person may see.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {URLSearchParams} [params]
|
||||
*/
|
||||
export async function listAccounts({ cookies }, params) {
|
||||
const query = new URLSearchParams(params ?? undefined);
|
||||
if (!query.has('limit')) query.set('limit', '25');
|
||||
|
||||
const response = await apiRequest(`/accounts/?${query}`, {}, { cookies });
|
||||
const active = response.active_accounts ?? {};
|
||||
const rows = (active.open_accounts ?? []).map(toRow);
|
||||
// Largest won first within a currency, as amounts only compare there; the
|
||||
// API orders `by_currency` by code, so an account is placed by its first.
|
||||
rows.sort((a, b) => {
|
||||
const x = a.won_by_currency?.[0];
|
||||
const y = b.won_by_currency?.[0];
|
||||
if (!x || !y) return (y ? 1 : 0) - (x ? 1 : 0);
|
||||
return x.currency === y.currency ? y.amount - x.amount : x.currency.localeCompare(y.currency);
|
||||
});
|
||||
|
||||
const inactive = response.closed_accounts ?? {};
|
||||
|
||||
return {
|
||||
results: rows,
|
||||
totals: {
|
||||
count: active.open_accounts_count ?? rows.length,
|
||||
// Every account with a deal won against it. This is the only definition
|
||||
// of "customer" the data supports; there is no customer flag.
|
||||
customers: rows.filter((row) => (row.won_count ?? 0) > 0).length,
|
||||
inactive: inactive.close_accounts_count ?? 0,
|
||||
shown: rows.length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts as a flat `{id, name}` picker list, open accounts only.
|
||||
*
|
||||
* Same `/accounts/?limit=200` shape `contacts.js:342` already fetches for its
|
||||
* own account picker, reused here rather than respelled a third time for the
|
||||
* invoices and estimates filter bars, which both need an Account field.
|
||||
*
|
||||
* The 200 ceiling is real: an org with more open accounts than that gets a
|
||||
* truncated picker, the same limit `contacts.js` already lives with.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @returns {Promise<{ accounts: {id: string, name: string}[] }>}
|
||||
*/
|
||||
export async function listAccountsPicker({ cookies }) {
|
||||
const response = await apiRequest('/accounts/?limit=200', {}, { cookies });
|
||||
const active = response.active_accounts ?? {};
|
||||
return {
|
||||
accounts: (active.open_accounts ?? []).map((/** @type {any} */ account) => ({
|
||||
id: account.id,
|
||||
name: account.name ?? ''
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One account and everything hanging off it.
|
||||
*
|
||||
* All of it arrives in a single request. `AccountDetailView.get` already
|
||||
* returns the deals, people, tickets and invoices for the account, so unlike
|
||||
* the pipeline's "attached" rail this panel costs nothing extra. It was the
|
||||
* response all along and the fixture page was reimplementing it.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getAccount({ cookies }, id) {
|
||||
const response = await fetchDetail(cookies, id);
|
||||
const account = toRow(response.account_obj);
|
||||
|
||||
return {
|
||||
account,
|
||||
owners: (response.account_obj.assigned_to ?? []).map(profileName),
|
||||
tags: (response.account_obj.tags ?? []).map((/** @type {any} */ tag) => tag.name),
|
||||
deals: (response.opportunity_list ?? []).map((/** @type {any} */ deal) => ({
|
||||
id: deal.id,
|
||||
name: deal.name ?? '',
|
||||
stage: deal.stage,
|
||||
// Named and classified by the deal's own pipeline, never by its code.
|
||||
stage_label: deal.stage_label ?? deal.stage,
|
||||
stage_kind: deal.stage_kind ?? null,
|
||||
amount: num(deal.amount) ?? 0,
|
||||
// Each deal is priced in its own currency, as the account's rollups
|
||||
// above are. Dropping this printed every deal on the page as dollars.
|
||||
currency: deal.currency || 'USD',
|
||||
closed_on: deal.closed_on ?? null,
|
||||
// Real fields, from the stage-aging chain the deal module already owns.
|
||||
days_in_current_stage: deal.days_in_stage ?? 0,
|
||||
aging_status: deal.aging_status ?? 'green'
|
||||
})),
|
||||
contacts: (response.contacts ?? []).map((/** @type {any} */ contact) => ({
|
||||
id: contact.id,
|
||||
first_name: contact.first_name ?? '',
|
||||
last_name: contact.last_name ?? '',
|
||||
title: contact.title ?? '',
|
||||
department: contact.department ?? '',
|
||||
email: contact.email ?? '',
|
||||
phone: contact.phone ?? '',
|
||||
do_not_call: Boolean(contact.do_not_call)
|
||||
})),
|
||||
tickets: (response.cases ?? []).map((/** @type {any} */ ticket) => ({
|
||||
id: ticket.id,
|
||||
name: ticket.name ?? '',
|
||||
status: ticket.status,
|
||||
priority: ticket.priority
|
||||
})),
|
||||
invoices: (response.invoices ?? []).map((/** @type {any} */ invoice) => ({
|
||||
id: invoice.id,
|
||||
invoice_number: invoice.invoice_number ?? '',
|
||||
status: invoice.status,
|
||||
total_amount: num(invoice.total_amount) ?? 0,
|
||||
amount_due: num(invoice.amount_due) ?? 0,
|
||||
currency: invoice.currency || 'USD',
|
||||
due_date: invoice.due_date ?? null,
|
||||
past_due: isPastDue(invoice)
|
||||
})),
|
||||
activity: (response.comments ?? []).map((/** @type {any} */ comment) => ({
|
||||
id: comment.id,
|
||||
at: comment.commented_on,
|
||||
by: comment.commented_by_user?.email || comment.commented_by?.user_details?.email || null,
|
||||
body: comment.comment
|
||||
})),
|
||||
// The API's delete rule for this caller, so Delete is offered only when it would work.
|
||||
canDelete: Boolean(response.can_delete)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} profile
|
||||
*/
|
||||
function profileName(profile) {
|
||||
return profile?.user_details?.email || profile?.user?.email || 'Unknown';
|
||||
}
|
||||
|
||||
/** Scalar fields the account forms own. Everything else is server-derived. */
|
||||
export const EDITABLE_FIELDS = [
|
||||
'name',
|
||||
'industry',
|
||||
'website',
|
||||
'email',
|
||||
'phone',
|
||||
'number_of_employees',
|
||||
'annual_revenue',
|
||||
'address_line',
|
||||
'city',
|
||||
'state',
|
||||
'postcode',
|
||||
'country',
|
||||
'description'
|
||||
];
|
||||
|
||||
/**
|
||||
* The choice lists the forms need, taken from the API rather than hardcoded.
|
||||
*
|
||||
* `industries` and `countries` come back as `[value, label]` pairs on every
|
||||
* accounts list response, the same lists the model validates against, so a
|
||||
* select built from them cannot offer a value the serializer will reject.
|
||||
*
|
||||
* @param {import('@sveltejs/kit').Cookies} cookies
|
||||
*/
|
||||
async function listChoices(cookies) {
|
||||
const response = await apiRequest('/accounts/?limit=1', {}, { cookies });
|
||||
return {
|
||||
industries: (response.industries ?? []).map((/** @type {any} */ pair) => ({
|
||||
value: pair[0],
|
||||
label: industryLabel(pair[0])
|
||||
})),
|
||||
countries: (response.countries ?? []).map((/** @type {any} */ pair) => ({
|
||||
value: pair[0],
|
||||
label: pair[1]
|
||||
})),
|
||||
owners: (response.users ?? []).map((/** @type {any} */ user) => ({
|
||||
id: user.id,
|
||||
name: user.user__email
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the create form needs, in one call.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
*/
|
||||
export async function getAccountFormOptions({ cookies }) {
|
||||
const choices = await listChoices(cookies);
|
||||
return { ...choices, defaults: { country: '', industry: '' } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The account plus what the edit form needs in order to explain itself.
|
||||
*
|
||||
* `form` holds only `EDITABLE_FIELDS`, so no input can bind to a
|
||||
* server-derived field by accident.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getAccountForEdit({ cookies }, id) {
|
||||
const [response, choices] = await Promise.all([fetchDetail(cookies, id), listChoices(cookies)]);
|
||||
const raw = response.account_obj;
|
||||
const account = toRow(raw);
|
||||
|
||||
return {
|
||||
account,
|
||||
...choices,
|
||||
form: {
|
||||
name: account.name,
|
||||
industry: account.industry_value,
|
||||
website: account.website,
|
||||
email: account.email,
|
||||
phone: account.phone,
|
||||
number_of_employees: account.number_of_employees ?? '',
|
||||
annual_revenue: account.annual_revenue ?? '',
|
||||
address_line: raw.address_line ?? '',
|
||||
city: account.city,
|
||||
state: raw.state ?? '',
|
||||
postcode: raw.postcode ?? '',
|
||||
country: account.country,
|
||||
description: account.description,
|
||||
// Binds to a Profile id, not a display name.
|
||||
assigned_to: raw.assigned_to?.[0]?.id ?? ''
|
||||
},
|
||||
server: {
|
||||
// Read-only context so the form can explain what it is not editing.
|
||||
contact_count: (response.contacts ?? []).length,
|
||||
deal_count: (response.opportunity_list ?? []).length,
|
||||
invoice_count: (response.invoices ?? []).length,
|
||||
owner_count: (raw.assigned_to ?? []).length,
|
||||
team_count: (raw.teams ?? []).length,
|
||||
tag_count: (raw.tags ?? []).length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn form values into a request body.
|
||||
*
|
||||
* Absent stays absent. A field the form did not send is a field the save
|
||||
* leaves alone, which is the whole reason for PATCH below.
|
||||
*
|
||||
* @param {Record<string, any>} values
|
||||
*/
|
||||
function toBody(values) {
|
||||
/** @type {Record<string, any>} */
|
||||
const body = {};
|
||||
for (const field of EDITABLE_FIELDS) {
|
||||
if (!(field in values)) continue;
|
||||
const value = values[field];
|
||||
body[field] = value === '' ? null : value;
|
||||
}
|
||||
if ('number_of_employees' in body && body.number_of_employees !== null) {
|
||||
body.number_of_employees = Number(body.number_of_employees);
|
||||
}
|
||||
if ('annual_revenue' in body && body.annual_revenue !== null) {
|
||||
body.annual_revenue = Number(body.annual_revenue);
|
||||
}
|
||||
// Single-select owner, so the list is empty or one long. Only present when
|
||||
// the form decided it changed. See the action for why that matters.
|
||||
if ('assigned_to' in values) {
|
||||
body.assigned_to = values.assigned_to ? [values.assigned_to] : [];
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the edit form.
|
||||
*
|
||||
* PATCH, not PUT, and here the reason is louder than it was for leads or
|
||||
* deals: `AccountDetailView.put` runs `contacts.clear()`, `tags.clear()`,
|
||||
* `teams.clear()` and `assigned_to.clear()` unconditionally before re-adding
|
||||
* whatever the body carried. This form owns thirteen scalar fields and one
|
||||
* owner, so a single PUT would strip every contact off the account, the
|
||||
* people, on the record whose entire purpose is the people. `patch` guards
|
||||
* each relation with `if "<field>" in data`.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} id
|
||||
* @param {Record<string, any>} values
|
||||
*/
|
||||
export async function updateAccount({ cookies }, id, values) {
|
||||
return await apiRequest(
|
||||
`/accounts/${id}/`,
|
||||
{ method: 'PATCH', body: toBody(values) },
|
||||
{ cookies }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete this account for good. The API applies the delete rule (an admin or
|
||||
* the account's creator) and answers 404 for one the caller cannot open; the
|
||||
* page offers the action only when the detail response said `can_delete`.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function deleteAccount({ cookies }, id) {
|
||||
return await apiRequest(`/accounts/${id}/`, { method: 'DELETE' }, { cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an account.
|
||||
*
|
||||
* No `org` and no `created_by` in the body: `AccountsListView.post` sets both
|
||||
* from `request.profile`, and `AccountCreateSerializer` does not list either
|
||||
* as a field.
|
||||
*
|
||||
* The response now carries the new `id`, which it did not before this change.
|
||||
* Without it a client cannot open what it just made, and searching by name is
|
||||
* a race dressed up as a lookup.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {Record<string, any>} values
|
||||
*/
|
||||
export async function createAccount({ cookies }, values) {
|
||||
return await apiRequest('/accounts/', { method: 'POST', body: toBody(values) }, { cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* `AccountDetailView.get` answers 404, with one body, both for an account that
|
||||
* does not exist and for one this profile may not open (another org's, or one
|
||||
* in this org they neither created nor are assigned to). Deliberately not
|
||||
* 403, which would confirm the id exists, so the copy covers both cases.
|
||||
*
|
||||
* @param {import('@sveltejs/kit').Cookies} cookies
|
||||
* @param {string} id
|
||||
*/
|
||||
async function fetchDetail(cookies, id) {
|
||||
try {
|
||||
return await apiRequest(`/accounts/${id}/`, {}, { cookies });
|
||||
} catch (/** @type {any} */ err) {
|
||||
// On the status, not on the wording. Django answers a missing record with
|
||||
// "No Account matches the given query.", no "404" in it anywhere.
|
||||
if (err?.status === 404) {
|
||||
error(404, 'That account does not exist, or you do not have access to it.');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
82
frontend/src/lib/server/v2/accounts.test.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const apiRequest = vi.fn();
|
||||
vi.mock('#lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
|
||||
|
||||
const { listAccounts } = await import('#lib/server/v2/accounts.js');
|
||||
|
||||
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
|
||||
|
||||
/**
|
||||
* An account row as the API sends it, rollups included.
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {any[]} byCurrency
|
||||
*/
|
||||
const account = (id, byCurrency) => ({
|
||||
id,
|
||||
name: id,
|
||||
rollups: {
|
||||
won_count: 1,
|
||||
open_deal_count: 0,
|
||||
open_tickets: 0,
|
||||
first_won_on: null,
|
||||
won_amount: byCurrency.length === 1 ? byCurrency[0].won_amount : null,
|
||||
open_pipeline: byCurrency.length === 1 ? byCurrency[0].open_pipeline : null,
|
||||
overdue_amount: byCurrency.length === 1 ? byCurrency[0].overdue_amount : null,
|
||||
by_currency: byCurrency
|
||||
}
|
||||
});
|
||||
|
||||
/** @param {any[]} rows */
|
||||
const respond = (rows) =>
|
||||
apiRequest.mockResolvedValue({
|
||||
active_accounts: { open_accounts: rows, open_accounts_count: rows.length },
|
||||
closed_accounts: {}
|
||||
});
|
||||
|
||||
describe('account rollups', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
it('keeps each currency on its own and drops the empty ones', async () => {
|
||||
respond([
|
||||
account('a', [
|
||||
{ currency: 'EUR', won_amount: '300.00', open_pipeline: '0', overdue_amount: '70.00' },
|
||||
{ currency: 'USD', won_amount: '1000.00', open_pipeline: '50.00', overdue_amount: '0' }
|
||||
])
|
||||
]);
|
||||
const { results } = await listAccounts(event);
|
||||
const [row] = results;
|
||||
expect(row.won_by_currency).toEqual([
|
||||
{ currency: 'EUR', amount: 300 },
|
||||
{ currency: 'USD', amount: 1000 }
|
||||
]);
|
||||
expect(row.pipeline_by_currency).toEqual([{ currency: 'USD', amount: 50 }]);
|
||||
expect(row.overdue_by_currency).toEqual([{ currency: 'EUR', amount: 70 }]);
|
||||
expect(row).not.toHaveProperty('won_amount');
|
||||
});
|
||||
|
||||
it('an account with nothing has empty lists, not zeros', async () => {
|
||||
respond([account('a', [])]);
|
||||
const [row] = (await listAccounts(event)).results;
|
||||
expect(row.won_by_currency).toEqual([]);
|
||||
expect(row.pipeline_by_currency).toEqual([]);
|
||||
expect(row.overdue_by_currency).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts largest won first within a currency, never across two', async () => {
|
||||
const won = (/** @type {string} */ currency, /** @type {string} */ amount) => [
|
||||
{ currency, won_amount: amount, open_pipeline: '0', overdue_amount: '0' }
|
||||
];
|
||||
respond([
|
||||
account('none', []),
|
||||
account('usd-small', won('USD', '10')),
|
||||
account('eur-small', won('EUR', '5')),
|
||||
account('usd-big', won('USD', '500'))
|
||||
]);
|
||||
const { results } = await listAccounts(event);
|
||||
expect(results.map((r) => r.id)).toEqual(['eur-small', 'usd-big', 'usd-small', 'none']);
|
||||
});
|
||||
});
|
||||
183
frontend/src/lib/server/v2/approvals.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* Case approvals: the wiring behind /v2/tickets/approvals.
|
||||
*
|
||||
* Server-only. The inbox (`GET /cases/approvals/?state=all`) and the rule list
|
||||
* (`GET /cases/approval-rules/`) are fanned into the single shape the queue
|
||||
* reads, and the three decisions (approve / reject / cancel) POST to their
|
||||
* endpoints. The inbox has no totals envelope, so the header numbers are
|
||||
* computed here from the rows, same approach as recurring invoices.
|
||||
*
|
||||
* The action guard `can_act` and `is_own_request` come straight from the
|
||||
* serializer (the server knows the rule pool and the viewer's identity); the
|
||||
* page never recomputes them, because guessing wrong means a button that 403s.
|
||||
* Since the self-approval guard landed, `can_act` is already false on a row you
|
||||
* filed yourself, so the queue's Approve/Reject offer and the endpoint agree.
|
||||
*/
|
||||
import { apiRequest } from '#lib/api-helpers.js';
|
||||
|
||||
/** A `{ id, email }` profile object (or null) → a display string. */
|
||||
const label = (/** @type {any} */ p) => (p && typeof p === 'object' ? p.email || '' : p || '');
|
||||
|
||||
function toRow(/** @type {any} */ a) {
|
||||
const cs = a.case_summary || {};
|
||||
const rs = a.rule_summary || {};
|
||||
return {
|
||||
id: a.id,
|
||||
state: a.state,
|
||||
note: a.note || '',
|
||||
reason: a.reason || '',
|
||||
created_at: a.created_at,
|
||||
decided_at: a.decided_at,
|
||||
can_act: !!a.can_act,
|
||||
// The cancel endpoint's own rule (requester or org admin, while pending).
|
||||
can_cancel: a.can_cancel === true,
|
||||
is_own_request: !!a.is_own_request,
|
||||
requested_by: label(a.requested_by),
|
||||
approver: label(a.approver),
|
||||
case: {
|
||||
id: cs.id,
|
||||
name: cs.name,
|
||||
priority: cs.priority,
|
||||
account: cs.account ? { id: cs.account.id, name: cs.account.name } : null
|
||||
},
|
||||
rule: {
|
||||
id: rs.id,
|
||||
name: rs.name,
|
||||
approver_role: rs.approver_role || '',
|
||||
approvers: (rs.approvers || []).map(label)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toRuleRow(/** @type {any} */ r, /** @type {Record<string, number>} */ pendingByRule) {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
match_priority: r.match_priority,
|
||||
match_case_type: r.match_case_type,
|
||||
match_team: r.match_team ? { id: r.match_team.id, name: r.match_team.name } : null,
|
||||
approver_role: r.approver_role || '',
|
||||
approvers: (r.approvers || []).map(label),
|
||||
is_active: r.is_active,
|
||||
pending_count: pendingByRule[r.id] || 0
|
||||
};
|
||||
}
|
||||
|
||||
function computeTotals(/** @type {any[]} */ rows) {
|
||||
const now = Date.now();
|
||||
const pending = rows.filter((r) => r.state === 'pending');
|
||||
const oldest = pending.reduce((max, r) => {
|
||||
const hrs = (now - Date.parse(r.created_at)) / 3_600_000;
|
||||
return Number.isFinite(hrs) && hrs > max ? hrs : max;
|
||||
}, 0);
|
||||
const weekAgo = now - 7 * 86_400_000;
|
||||
return {
|
||||
// What is waiting on *this viewer* specifically. The only figure a rep can
|
||||
// act on. `can_act` already excludes rows they filed themselves.
|
||||
awaiting_you: rows.filter((r) => r.can_act).length,
|
||||
pending: pending.length,
|
||||
oldest_pending_hours: Math.round(oldest),
|
||||
decided_this_week: rows.filter(
|
||||
(r) => r.state !== 'pending' && r.decided_at && Date.parse(r.decided_at) >= weekAgo
|
||||
).length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The approvals queue, shaped for the page: `{ approvals, totals, rules }`.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
*/
|
||||
export async function listApprovals({ cookies }) {
|
||||
const [inbox, rulesResp] = await Promise.all([
|
||||
apiRequest('/cases/approvals/?state=all', {}, { cookies }),
|
||||
apiRequest('/cases/approval-rules/', {}, { cookies })
|
||||
]);
|
||||
|
||||
const raw = inbox.approvals || [];
|
||||
const rows = raw.map(toRow);
|
||||
|
||||
/** @type {Record<string, number>} */
|
||||
const pendingByRule = {};
|
||||
for (const a of raw) {
|
||||
if (a.state === 'pending' && a.rule_summary?.id) {
|
||||
pendingByRule[a.rule_summary.id] = (pendingByRule[a.rule_summary.id] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const rules = (rulesResp.rules || []).map((/** @type {any} */ r) => toRuleRow(r, pendingByRule));
|
||||
|
||||
return { approvals: rows, totals: computeTotals(rows), rules };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of pending approvals the viewer can act on, the nav badge. Lighter
|
||||
* than {@link listApprovals}: `mine=true` already scopes to the actionable set.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
*/
|
||||
export async function countAwaitingApprovals({ cookies }) {
|
||||
const resp = await apiRequest('/cases/approvals/?mine=true&state=pending', {}, { cookies });
|
||||
return (resp.approvals || []).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every approval filed on one ticket, newest first, in the queue's row shape.
|
||||
* The inbox endpoint's own visibility rule decides what comes back, and the
|
||||
* viewer-relative `can_act` / `is_own_request` ride on each row.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} caseId
|
||||
*/
|
||||
export async function listTicketApprovals({ cookies }, caseId) {
|
||||
const query = new URLSearchParams({ case: caseId, state: 'all' });
|
||||
const inbox = await apiRequest(`/cases/approvals/?${query}`, {}, { cookies });
|
||||
return (inbox.approvals || []).map(toRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* File an approval request on a ticket. No `rule_id`: the API binds it to the
|
||||
* rule that gates closing this ticket, the one the detail names as
|
||||
* `approval_rule`.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} caseId
|
||||
* @param {string} note
|
||||
*/
|
||||
export async function requestApproval({ cookies }, caseId, note) {
|
||||
return apiRequest(
|
||||
`/cases/${caseId}/request-approval/`,
|
||||
{ method: 'POST', body: note ? { note } : {} },
|
||||
{ cookies }
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
|
||||
export async function approveApproval(
|
||||
{ cookies },
|
||||
/** @type {string} */ id,
|
||||
/** @type {string} */ note
|
||||
) {
|
||||
return apiRequest(
|
||||
`/cases/approvals/${id}/approve/`,
|
||||
{ method: 'POST', body: note ? { note } : {} },
|
||||
{ cookies }
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
|
||||
export async function rejectApproval(
|
||||
{ cookies },
|
||||
/** @type {string} */ id,
|
||||
/** @type {string} */ reason
|
||||
) {
|
||||
return apiRequest(
|
||||
`/cases/approvals/${id}/reject/`,
|
||||
{ method: 'POST', body: { reason } },
|
||||
{ cookies }
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
|
||||
export async function cancelApproval({ cookies }, /** @type {string} */ id) {
|
||||
return apiRequest(`/cases/approvals/${id}/cancel/`, { method: 'POST', body: {} }, { cookies });
|
||||
}
|
||||
81
frontend/src/lib/server/v2/audit-log.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* The security audit log: the wiring behind `/settings/audit-log`.
|
||||
*
|
||||
* Server-only. `GET /api/org/audit-log/` is admin-only
|
||||
* (`common/views/audit_log_views.py`), so a member's 403 folds into
|
||||
* `forbidden` and the page says "Admins only" instead of crashing, the shape
|
||||
* `listWebhooks` uses. A 400 is a filter the API refused (a hand-edited URL);
|
||||
* the page shows the reason beside an empty list rather than an error page.
|
||||
*
|
||||
* Filters travel in the page URL and are passed on as they are. The API is
|
||||
* what validates them. Token refreshes are hidden by the API unless
|
||||
* `include_token_refresh=true` is sent (or `event_type=TOKEN_REFRESH`).
|
||||
*/
|
||||
import { apiRequest } from '#lib/api-helpers.js';
|
||||
|
||||
/** Entries per page. */
|
||||
export const AUDIT_PAGE = 25;
|
||||
|
||||
/** The query parameters the API filters on. Nothing else is forwarded. */
|
||||
export const AUDIT_FILTERS = /** @type {const} */ ([
|
||||
'event_type',
|
||||
'actor',
|
||||
'from',
|
||||
'to',
|
||||
'include_token_refresh'
|
||||
]);
|
||||
|
||||
/**
|
||||
* The filters set in the page URL, blank ones left out.
|
||||
* @param {URLSearchParams} params
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function auditFilters(params) {
|
||||
/** @type {Record<string, string>} */
|
||||
const out = {};
|
||||
for (const key of AUDIT_FILTERS) {
|
||||
const value = params.get(key)?.trim();
|
||||
if (value) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the caller's org's audit log.
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {Record<string, string>} filters
|
||||
* @param {number} offset
|
||||
*/
|
||||
export async function listAuditLog({ cookies }, filters, offset = 0) {
|
||||
const query = new URLSearchParams({
|
||||
...filters,
|
||||
limit: String(AUDIT_PAGE),
|
||||
offset: String(offset)
|
||||
});
|
||||
const page = { filters, offset, pageSize: AUDIT_PAGE };
|
||||
let resp;
|
||||
try {
|
||||
resp = await apiRequest(`/org/audit-log/?${query}`, {}, { cookies });
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (err?.status === 403) return { forbidden: true };
|
||||
if (err?.status === 400) {
|
||||
return {
|
||||
forbidden: false,
|
||||
...page,
|
||||
error: err.message || 'That filter is not valid.',
|
||||
entries: [],
|
||||
count: 0,
|
||||
eventTypes: []
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
forbidden: false,
|
||||
...page,
|
||||
error: null,
|
||||
entries: resp?.results ?? [],
|
||||
count: resp?.count ?? 0,
|
||||
eventTypes: resp?.event_types ?? []
|
||||
};
|
||||
}
|
||||
67
frontend/src/lib/server/v2/audit-log.test.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const apiRequest = vi.fn();
|
||||
vi.mock('#lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
|
||||
|
||||
const { auditFilters, listAuditLog, AUDIT_PAGE } = await import('#lib/server/v2/audit-log.js');
|
||||
|
||||
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
|
||||
|
||||
describe('auditFilters', () => {
|
||||
it('forwards only the known filters, blank ones dropped', () => {
|
||||
const params = new URLSearchParams(
|
||||
'event_type=ORG_SWITCH&actor=&from=2026-09-01&org=attacker&to=%20'
|
||||
);
|
||||
expect(auditFilters(params)).toEqual({ event_type: 'ORG_SWITCH', from: '2026-09-01' });
|
||||
});
|
||||
|
||||
it('forwards the token refresh toggle when it is on', () => {
|
||||
const params = new URLSearchParams('include_token_refresh=true');
|
||||
expect(auditFilters(params)).toEqual({ include_token_refresh: 'true' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAuditLog', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
it('asks for one page with the filters', async () => {
|
||||
apiRequest.mockResolvedValue({ count: 1, results: [{ id: 'a' }], event_types: [] });
|
||||
const out = await listAuditLog(event, { actor: 'u1' }, 25);
|
||||
const url = apiRequest.mock.calls[0][0];
|
||||
expect(url.startsWith('/org/audit-log/?')).toBe(true);
|
||||
const q = new URLSearchParams(url.split('?')[1]);
|
||||
expect(q.get('actor')).toBe('u1');
|
||||
expect(q.has('include_token_refresh')).toBe(false);
|
||||
expect(q.get('limit')).toBe(String(AUDIT_PAGE));
|
||||
expect(q.get('offset')).toBe('25');
|
||||
expect(out).toMatchObject({ forbidden: false, count: 1, entries: [{ id: 'a' }], error: null });
|
||||
});
|
||||
|
||||
it('passes the token refresh toggle on to the API', async () => {
|
||||
apiRequest.mockResolvedValue({ count: 0, results: [], event_types: [] });
|
||||
await listAuditLog(event, { include_token_refresh: 'true' }, 0);
|
||||
const q = new URLSearchParams(apiRequest.mock.calls[0][0].split('?')[1]);
|
||||
expect(q.get('include_token_refresh')).toBe('true');
|
||||
});
|
||||
|
||||
it('folds a 403 into forbidden', async () => {
|
||||
apiRequest.mockRejectedValue(Object.assign(new Error('no'), { status: 403 }));
|
||||
expect(await listAuditLog(event, {}, 0)).toEqual({ forbidden: true });
|
||||
});
|
||||
|
||||
it('shows a refused filter beside an empty list', async () => {
|
||||
apiRequest.mockRejectedValue(
|
||||
Object.assign(new Error('event_type: Unknown event type.'), { status: 400 })
|
||||
);
|
||||
const out = /** @type {any} */ (await listAuditLog(event, { event_type: 'X' }, 0));
|
||||
expect(out).toMatchObject({ forbidden: false, entries: [], count: 0 });
|
||||
expect(out.error).toMatch(/Unknown event type/);
|
||||
});
|
||||
|
||||
it('lets any other failure through', async () => {
|
||||
apiRequest.mockRejectedValue(Object.assign(new Error('boom'), { status: 500 }));
|
||||
await expect(listAuditLog(event, {}, 0)).rejects.toThrow('boom');
|
||||
});
|
||||
});
|
||||
296
frontend/src/lib/server/v2/automation.js
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* The automation builder's API, behind `/settings/automation`. Server-only.
|
||||
* The contract is the code in `backend/automation/`.
|
||||
*/
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { apiRequest } from '#lib/api-helpers.js';
|
||||
import { readableError } from '#lib/server/v2/form-errors.js';
|
||||
import { checkId } from '#lib/server/v2/custom-objects.js';
|
||||
|
||||
export const RULE_GONE = 'That rule does not exist, or you do not have access to it.';
|
||||
// The hosted web app can run ahead of its API during a release, so an API
|
||||
// without these routes reads as "not offered", as custom objects do.
|
||||
export const NOT_AVAILABLE = 'Automation rules are not available here.';
|
||||
const UNREADABLE = 'Could not read the rule.';
|
||||
|
||||
/** Runs per page on a rule's history. */
|
||||
export const RUN_PAGE = 20;
|
||||
|
||||
/** @typedef {{ cookies: import('@sveltejs/kit').Cookies }} Event */
|
||||
|
||||
/** @param {Event} event */
|
||||
export async function getCatalogue({ cookies }) {
|
||||
try {
|
||||
return await apiRequest(
|
||||
'/automation/catalogue/',
|
||||
{ expectedStatuses: [403, 404] },
|
||||
{ cookies }
|
||||
);
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (err?.status === 404) error(404, NOT_AVAILABLE);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Event} event */
|
||||
export async function listRules({ cookies }) {
|
||||
try {
|
||||
const resp = await apiRequest(
|
||||
'/automation/rules/',
|
||||
{ expectedStatuses: [403, 404] },
|
||||
{ cookies }
|
||||
);
|
||||
return { summary: resp?.summary ?? null, rules: resp?.results ?? [] };
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (err?.status === 403) return { forbidden: true };
|
||||
if (err?.status === 404) error(404, NOT_AVAILABLE);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getRule({ cookies }, id) {
|
||||
checkId(id);
|
||||
try {
|
||||
return await apiRequest(
|
||||
`/automation/rules/${id}/`,
|
||||
{ expectedStatuses: [403, 404] },
|
||||
{ cookies }
|
||||
);
|
||||
} catch (/** @type {any} */ err) {
|
||||
if (err?.status === 404) error(404, RULE_GONE);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {Record<string, any>} body
|
||||
*/
|
||||
export async function createRule({ cookies }, body) {
|
||||
return await apiRequest('/automation/rules/', { method: 'POST', body }, { cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {string} id
|
||||
* @param {Record<string, any>} body
|
||||
*/
|
||||
export async function updateRule({ cookies }, id, body) {
|
||||
checkId(id);
|
||||
return await apiRequest(`/automation/rules/${id}/`, { method: 'PATCH', body }, { cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function switchOff(event, id) {
|
||||
return await updateRule(event, id, { is_active: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function deleteRule({ cookies }, id) {
|
||||
checkId(id);
|
||||
await apiRequest(`/automation/rules/${id}/`, { method: 'DELETE' }, { cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {string} id
|
||||
* @param {string} recordId
|
||||
* @returns {Promise<any[]>}
|
||||
*/
|
||||
export async function tryRule({ cookies }, id, recordId) {
|
||||
checkId(id);
|
||||
const resp = await apiRequest(
|
||||
`/automation/rules/${id}/try/`,
|
||||
{ method: 'POST', body: { record_id: recordId } },
|
||||
{ cookies }
|
||||
);
|
||||
return resp?.steps ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {string} id
|
||||
* @param {number} rawOffset
|
||||
*/
|
||||
export async function listRuns({ cookies }, id, rawOffset) {
|
||||
checkId(id);
|
||||
const whole = Math.trunc(Number(rawOffset));
|
||||
const offset = Number.isSafeInteger(whole) ? Math.max(0, whole) : 0;
|
||||
const resp = await apiRequest(
|
||||
`/automation/rules/${id}/runs/?limit=${RUN_PAGE}&offset=${offset}`,
|
||||
{},
|
||||
{ cookies }
|
||||
);
|
||||
return { count: resp?.count ?? 0, runs: resp?.results ?? [], offset, pageSize: RUN_PAGE };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
* @param {string[]} ids
|
||||
*/
|
||||
export async function reorderRules({ cookies }, ids) {
|
||||
return await apiRequest(
|
||||
'/automation/rules/reorder/',
|
||||
{ method: 'POST', body: { ids } },
|
||||
{ cookies }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Settings row's numbers. Null when they cannot be read, so the row shows
|
||||
* without them; false when the API has no automation, so the row is hidden.
|
||||
* @param {Event} event
|
||||
* @returns {Promise<{ active: number, failing: number, attention: boolean } | null | false>}
|
||||
*/
|
||||
export async function automationSummary({ cookies }) {
|
||||
try {
|
||||
const resp = await apiRequest('/automation/rules/', { quietErrors: true }, { cookies });
|
||||
const rules = Array.isArray(resp?.results) ? resp.results : [];
|
||||
let active = 0;
|
||||
let failing = 0;
|
||||
let attention = false;
|
||||
for (const r of rules) {
|
||||
const bad = r.failures_7d > 0 || r.status === 'needs_attention';
|
||||
if (r.is_active) active += 1;
|
||||
if (bad) failing += 1;
|
||||
if (bad || r.status === 'paused') attention = true;
|
||||
}
|
||||
return { active, failing, attention };
|
||||
} catch (/** @type {any} */ err) {
|
||||
return err?.status === 404 ? false : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} source
|
||||
* @param {string[]} keys
|
||||
*/
|
||||
function pick(source, keys) {
|
||||
if (source === null || typeof source !== 'object' || Array.isArray(source)) {
|
||||
throw new Error(UNREADABLE);
|
||||
}
|
||||
/** @type {Record<string, any>} */
|
||||
const out = {};
|
||||
for (const key of keys) if (Object.hasOwn(source, key)) out[key] = source[key];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @param {any} value */
|
||||
function list(value) {
|
||||
if (!Array.isArray(value)) throw new Error(UNREADABLE);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @type {Record<string, string[]>} */
|
||||
const ACTION_KEYS = {
|
||||
set_field: ['target', 'field', 'value'],
|
||||
assign: ['mode', 'people'],
|
||||
create_task: ['title', 'due_in_days', 'priority', 'assign_to', 'person'],
|
||||
notify: ['to', 'people', 'message'],
|
||||
webhook: ['endpoint']
|
||||
};
|
||||
|
||||
/**
|
||||
* A rule rebuilt key by key from parsed JSON; the server validates the values.
|
||||
* @param {any} raw
|
||||
* @param {{ create: boolean }} options
|
||||
*/
|
||||
export function ruleBody(raw, { create }) {
|
||||
const body = pick(raw, ['name', 'match', 'trigger', 'conditions', 'actions']);
|
||||
if (create && Object.hasOwn(raw, 'object_key')) body.object_key = raw.object_key;
|
||||
if (Object.hasOwn(raw, 'trigger')) body.trigger = pick(raw.trigger, ['event', 'field', 'to']);
|
||||
if (Object.hasOwn(raw, 'conditions')) {
|
||||
body.conditions = list(raw.conditions).map((c) => pick(c, ['field', 'op', 'value']));
|
||||
}
|
||||
if (Object.hasOwn(raw, 'actions')) {
|
||||
body.actions = list(raw.actions).map((a) => {
|
||||
const type = pick(a, ['type']).type;
|
||||
const keys =
|
||||
typeof type === 'string' && Object.hasOwn(ACTION_KEYS, type) ? ACTION_KEYS[type] : [];
|
||||
return pick(a, ['type', ...keys]);
|
||||
});
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's `rule` field, which carries the whole draft as JSON, rebuilt by
|
||||
* `ruleBody`. Throws `Could not read the rule.` for anything else.
|
||||
* @param {FormData} form
|
||||
* @param {{ create: boolean }} options
|
||||
*/
|
||||
export function ruleFromForm(form, options) {
|
||||
const raw = form.get('rule');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(typeof raw === 'string' ? raw : '');
|
||||
} catch {
|
||||
throw new Error(UNREADABLE);
|
||||
}
|
||||
return ruleBody(parsed, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused save as the editor shows it, with the API's status.
|
||||
* @param {any} err
|
||||
*/
|
||||
export function refusedSave(err) {
|
||||
const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 600;
|
||||
return fail(status ? err.status : 400, { save: ruleErrors(err) });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} ids
|
||||
* @param {string} id
|
||||
* @param {string} direction `up` or `down`
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
export function moveRule(ids, id, direction) {
|
||||
const at = ids.indexOf(id);
|
||||
if (at === -1 || (direction !== 'up' && direction !== 'down')) return null;
|
||||
const to = direction === 'up' ? at - 1 : at + 1;
|
||||
if (to < 0 || to >= ids.length) return null;
|
||||
const next = [...ids];
|
||||
[next[at], next[to]] = [next[to], next[at]];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** @param {string} path */
|
||||
function rowKey(path) {
|
||||
const m = /^(conditions|actions)\[(\d+)\]/.exec(path);
|
||||
if (m) return `${m[1]}.${m[2]}`;
|
||||
if (path === 'trigger' || path.startsWith('trigger.')) return 'trigger';
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} err the error thrown by `apiRequest`
|
||||
* @returns {{ general: string | null, rows: Record<string, string> }}
|
||||
*/
|
||||
export function ruleErrors(err) {
|
||||
/** @type {Record<string, string>} */
|
||||
const rows = {};
|
||||
const errors = err?.body?.errors;
|
||||
if (err?.status === 400 && errors && typeof errors === 'object' && !Array.isArray(errors)) {
|
||||
for (const [path, messages] of Object.entries(errors)) {
|
||||
const message = Array.isArray(messages) ? messages[0] : messages;
|
||||
const key = rowKey(path);
|
||||
if (typeof message === 'string' && !Object.hasOwn(rows, key)) rows[key] = message;
|
||||
}
|
||||
if (Object.keys(rows).length) return { general: null, rows };
|
||||
}
|
||||
if (err?.status === 403 && typeof err?.body?.detail === 'string') {
|
||||
return { general: err.body.detail, rows };
|
||||
}
|
||||
return { general: readableError(err, 'Could not save the rule.'), rows };
|
||||
}
|
||||
324
frontend/src/lib/server/v2/automation.test.js
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const apiRequest = vi.fn();
|
||||
vi.mock('#lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
|
||||
|
||||
const {
|
||||
getCatalogue,
|
||||
listRules,
|
||||
getRule,
|
||||
createRule,
|
||||
updateRule,
|
||||
switchOff,
|
||||
deleteRule,
|
||||
tryRule,
|
||||
listRuns,
|
||||
reorderRules,
|
||||
automationSummary,
|
||||
NOT_AVAILABLE,
|
||||
ruleBody,
|
||||
moveRule,
|
||||
ruleErrors
|
||||
} = await import('#lib/server/v2/automation.js');
|
||||
|
||||
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
|
||||
const ID = '11111111-2222-3333-4444-555555555555';
|
||||
const status = (s, body) => Object.assign(new Error('x'), { status: s, body });
|
||||
|
||||
beforeEach(() => apiRequest.mockReset());
|
||||
|
||||
describe('requests', () => {
|
||||
it('builds each request on its route', async () => {
|
||||
apiRequest.mockResolvedValue({ results: [], steps: [], count: 0 });
|
||||
const rule = { name: 'A' };
|
||||
/** @type {[() => Promise<any>, string, string | undefined, any][]} */
|
||||
const table = [
|
||||
[() => getCatalogue(event), '/automation/catalogue/', undefined, undefined],
|
||||
[() => listRules(event), '/automation/rules/', undefined, undefined],
|
||||
[() => getRule(event, ID), `/automation/rules/${ID}/`, undefined, undefined],
|
||||
[() => createRule(event, rule), '/automation/rules/', 'POST', rule],
|
||||
[() => updateRule(event, ID, rule), `/automation/rules/${ID}/`, 'PATCH', rule],
|
||||
[() => switchOff(event, ID), `/automation/rules/${ID}/`, 'PATCH', { is_active: false }],
|
||||
[() => deleteRule(event, ID), `/automation/rules/${ID}/`, 'DELETE', undefined],
|
||||
[
|
||||
() => tryRule(event, ID, 'rec'),
|
||||
`/automation/rules/${ID}/try/`,
|
||||
'POST',
|
||||
{ record_id: 'rec' }
|
||||
],
|
||||
[
|
||||
() => listRuns(event, ID, 40),
|
||||
`/automation/rules/${ID}/runs/?limit=20&offset=40`,
|
||||
undefined,
|
||||
undefined
|
||||
],
|
||||
[() => reorderRules(event, [ID]), '/automation/rules/reorder/', 'POST', { ids: [ID] }]
|
||||
];
|
||||
for (const [call, path, method, body] of table) {
|
||||
apiRequest.mockClear();
|
||||
await call();
|
||||
const [endpoint, options] = apiRequest.mock.calls[0];
|
||||
expect(endpoint).toBe(path);
|
||||
expect(options.method).toBe(method);
|
||||
expect(options.body).toEqual(body);
|
||||
}
|
||||
});
|
||||
|
||||
it('shapes the try and runs answers', async () => {
|
||||
apiRequest.mockResolvedValueOnce({ steps: [{ action: 0 }] });
|
||||
expect(await tryRule(event, ID, 'r')).toEqual([{ action: 0 }]);
|
||||
apiRequest.mockResolvedValueOnce({ count: 1, results: [{ id: 'r1' }] });
|
||||
expect(await listRuns(event, ID, 20)).toEqual({
|
||||
count: 1,
|
||||
runs: [{ id: 'r1' }],
|
||||
offset: 20,
|
||||
pageSize: 20
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces the offset so a raw query value adds no parameter', async () => {
|
||||
apiRequest.mockResolvedValue({ count: 0, results: [] });
|
||||
const out = await listRuns(event, ID, /** @type {any} */ ('40&limit=100'));
|
||||
expect(out.offset).toBe(0);
|
||||
expect(apiRequest.mock.calls[0][0]).toBe(`/automation/rules/${ID}/runs/?limit=20&offset=0`);
|
||||
await listRuns(event, ID, /** @type {any} */ ('40'));
|
||||
expect(apiRequest.mock.calls[1][0]).toContain('offset=40');
|
||||
await listRuns(event, ID, -5);
|
||||
expect(apiRequest.mock.calls[2][0]).toContain('offset=0');
|
||||
await listRuns(event, ID, Number('9'.repeat(22)));
|
||||
expect(apiRequest.mock.calls[3][0]).toContain('offset=0');
|
||||
});
|
||||
|
||||
it('expects a 403 and a 404 on one rule so a member or a stale id logs no error', async () => {
|
||||
apiRequest.mockResolvedValue({ id: ID });
|
||||
await getRule(event, ID);
|
||||
expect(apiRequest.mock.calls[0][1].expectedStatuses).toEqual([403, 404]);
|
||||
});
|
||||
|
||||
it('expects a 403 and a 404 on the list and the catalogue, so a member or an API without automation logs no error', async () => {
|
||||
apiRequest.mockResolvedValue({ results: [] });
|
||||
await listRules(event);
|
||||
await getCatalogue(event);
|
||||
expect(apiRequest.mock.calls[0][1].expectedStatuses).toEqual([403, 404]);
|
||||
expect(apiRequest.mock.calls[1][1].expectedStatuses).toEqual([403, 404]);
|
||||
});
|
||||
|
||||
it('turns a 404 on the list or the catalogue into a page 404 that says rules are not offered', async () => {
|
||||
expect(NOT_AVAILABLE).toBe('Automation rules are not available here.');
|
||||
for (const call of [() => listRules(event), () => getCatalogue(event)]) {
|
||||
apiRequest.mockRejectedValueOnce(status(404));
|
||||
await expect(call()).rejects.toMatchObject({ status: 404, body: { message: NOT_AVAILABLE } });
|
||||
}
|
||||
apiRequest.mockRejectedValueOnce(status(403));
|
||||
await expect(getCatalogue(event)).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('refuses a malformed id before any request', async () => {
|
||||
await expect(getRule(event, '../x')).rejects.toMatchObject({ status: 404 });
|
||||
await expect(deleteRule(event, 'nope')).rejects.toMatchObject({ status: 404 });
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns a 403 list into forbidden, and a 404 rule into a SvelteKit 404', async () => {
|
||||
apiRequest.mockRejectedValueOnce(status(403));
|
||||
expect(await listRules(event)).toEqual({ forbidden: true });
|
||||
apiRequest.mockResolvedValueOnce({ summary: 'S', results: [{ id: 'a' }] });
|
||||
expect(await listRules(event)).toEqual({ summary: 'S', rules: [{ id: 'a' }] });
|
||||
apiRequest.mockRejectedValueOnce(status(500));
|
||||
await expect(listRules(event)).rejects.toMatchObject({ status: 500 });
|
||||
apiRequest.mockRejectedValueOnce(status(404));
|
||||
await expect(getRule(event, ID)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
body: { message: 'That rule does not exist, or you do not have access to it.' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('automationSummary', () => {
|
||||
it('answers null for the summary on 403, 500 and a network error', async () => {
|
||||
for (const err of [status(403), status(500), new TypeError('fetch failed')]) {
|
||||
apiRequest.mockRejectedValueOnce(err);
|
||||
expect(await automationSummary(event)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('answers false on a 404: this API has no automation', async () => {
|
||||
apiRequest.mockRejectedValueOnce(status(404));
|
||||
expect(await automationSummary(event)).toBe(false);
|
||||
});
|
||||
|
||||
it('counts active, failing and attention', async () => {
|
||||
apiRequest.mockResolvedValue({
|
||||
results: [
|
||||
{ is_active: true, status: 'on', failures_7d: 2 },
|
||||
{ is_active: false, status: 'paused', failures_7d: 0 },
|
||||
{ is_active: false, status: 'off', failures_7d: 0 },
|
||||
{ is_active: true, status: 'needs_attention', failures_7d: 0 }
|
||||
]
|
||||
});
|
||||
expect(await automationSummary(event)).toEqual({ active: 2, failing: 2, attention: true });
|
||||
expect(apiRequest.mock.calls[0][1].quietErrors).toBe(true);
|
||||
});
|
||||
|
||||
it('is calm when every rule is on and clean', async () => {
|
||||
apiRequest.mockResolvedValue({
|
||||
results: [{ is_active: true, status: 'on', failures_7d: 0 }]
|
||||
});
|
||||
expect(await automationSummary(event)).toEqual({ active: 1, failing: 0, attention: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ruleBody', () => {
|
||||
const raw = () => ({
|
||||
name: 'A',
|
||||
object_key: 'deal',
|
||||
owner: 'x',
|
||||
trigger: { event: 'updated', field: 'stage_kind', to: 'won', extra: 1 },
|
||||
match: 'all',
|
||||
conditions: [{ field: 'amount', op: 'gt', value: '0', junk: true }],
|
||||
actions: [
|
||||
{ type: 'notify', to: ['admins'], people: [], message: 'Won: {name}', target: 'record' }
|
||||
]
|
||||
});
|
||||
|
||||
it('rebuilds a rule key by key and drops everything else', () => {
|
||||
expect(ruleBody(raw(), { create: false })).toEqual({
|
||||
name: 'A',
|
||||
trigger: { event: 'updated', field: 'stage_kind', to: 'won' },
|
||||
match: 'all',
|
||||
conditions: [{ field: 'amount', op: 'gt', value: '0' }],
|
||||
actions: [{ type: 'notify', to: ['admins'], people: [], message: 'Won: {name}' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps object_key only on create', () => {
|
||||
expect(ruleBody(raw(), { create: true }).object_key).toBe('deal');
|
||||
expect('object_key' in ruleBody(raw(), { create: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the keys of every action type', () => {
|
||||
const body = ruleBody(
|
||||
{
|
||||
actions: [
|
||||
{ type: 'set_field', target: 'record', field: 'f', value: null, x: 1 },
|
||||
{ type: 'assign', mode: 'round_robin', people: ['p'], x: 1 },
|
||||
{
|
||||
type: 'create_task',
|
||||
title: 't',
|
||||
due_in_days: 2,
|
||||
priority: 'high',
|
||||
assign_to: 'me',
|
||||
person: 'p',
|
||||
x: 1
|
||||
},
|
||||
{ type: 'webhook', endpoint: 'e', x: 1 }
|
||||
]
|
||||
},
|
||||
{ create: false }
|
||||
);
|
||||
expect(body.actions).toEqual([
|
||||
{ type: 'set_field', target: 'record', field: 'f', value: null },
|
||||
{ type: 'assign', mode: 'round_robin', people: ['p'] },
|
||||
{
|
||||
type: 'create_task',
|
||||
title: 't',
|
||||
due_in_days: 2,
|
||||
priority: 'high',
|
||||
assign_to: 'me',
|
||||
person: 'p'
|
||||
},
|
||||
{ type: 'webhook', endpoint: 'e' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes a stored id the catalogue no longer lists through unchanged', () => {
|
||||
const body = ruleBody(
|
||||
{ actions: [{ type: 'webhook', endpoint: 'gone-id' }] },
|
||||
{ create: false }
|
||||
);
|
||||
expect(body.actions[0].endpoint).toBe('gone-id');
|
||||
});
|
||||
|
||||
it('refuses a body that is not an object, or a conditions value that is not a list', () => {
|
||||
for (const bad of [null, 'x', [], 3]) {
|
||||
expect(() => ruleBody(bad, { create: false })).toThrow('Could not read the rule.');
|
||||
}
|
||||
expect(() => ruleBody({ conditions: 'x' }, { create: false })).toThrow(
|
||||
'Could not read the rule.'
|
||||
);
|
||||
expect(() => ruleBody({ actions: {} }, { create: false })).toThrow('Could not read the rule.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveRule', () => {
|
||||
it('moves a rule up and down, and refuses the ends and unknown ids', () => {
|
||||
const ids = ['a', 'b', 'c'];
|
||||
expect(moveRule(ids, 'b', 'up')).toEqual(['b', 'a', 'c']);
|
||||
expect(moveRule(ids, 'b', 'down')).toEqual(['a', 'c', 'b']);
|
||||
expect(moveRule(ids, 'a', 'up')).toBeNull();
|
||||
expect(moveRule(ids, 'c', 'down')).toBeNull();
|
||||
expect(moveRule(ids, 'z', 'up')).toBeNull();
|
||||
expect(moveRule(ids, 'b', 'left')).toBeNull();
|
||||
expect(ids).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ruleErrors', () => {
|
||||
it('maps row paths to row keys, first message per row', () => {
|
||||
const err = status(400, {
|
||||
errors: {
|
||||
'conditions[0].value': ['Enter a number.'],
|
||||
'actions[1].type': ['Unknown action.'],
|
||||
'actions[1].people': ['Second.'],
|
||||
'trigger.field': ['This field is not available.']
|
||||
}
|
||||
});
|
||||
expect(ruleErrors(err)).toEqual({
|
||||
general: null,
|
||||
rows: {
|
||||
'conditions.0': 'Enter a number.',
|
||||
'actions.1': 'Unknown action.',
|
||||
trigger: 'This field is not available.'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps name, match and the list-level limits as they are', () => {
|
||||
const err = status(400, {
|
||||
errors: {
|
||||
name: ['Name it.'],
|
||||
match: ['Pick one.'],
|
||||
conditions: ['Too many.'],
|
||||
actions: ['Add one.'],
|
||||
trigger: ['Bad.']
|
||||
}
|
||||
});
|
||||
expect(ruleErrors(err).rows).toEqual({
|
||||
name: 'Name it.',
|
||||
match: 'Pick one.',
|
||||
conditions: 'Too many.',
|
||||
actions: 'Add one.',
|
||||
trigger: 'Bad.'
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a 403 detail as the general error', () => {
|
||||
const err = status(403, { detail: 'Not available on this install', code: 'plan_limit' });
|
||||
expect(ruleErrors(err)).toEqual({ general: 'Not available on this install', rows: {} });
|
||||
});
|
||||
|
||||
it('still gives a message when a 400 maps to no rows', () => {
|
||||
for (const errors of [{}, { name: [] }, { name: [3] }]) {
|
||||
const out = ruleErrors(status(400, { errors }));
|
||||
expect(out.rows).toEqual({});
|
||||
expect(typeof out.general).toBe('string');
|
||||
expect(out.general).not.toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a readable message for anything else', () => {
|
||||
const out = ruleErrors(status(500, {}));
|
||||
expect(out.rows).toEqual({});
|
||||
expect(typeof out.general).toBe('string');
|
||||
});
|
||||
});
|
||||
167
frontend/src/lib/server/v2/board.js
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* Kanban boards: the wiring behind /v2/tasks/board.
|
||||
*
|
||||
* Server-only. A board is read in two calls: `GET /boards/?archived=false` for
|
||||
* the picker (and to resolve the active board), then
|
||||
* `GET /boards/<id>/columns/`, which nests each column's cards. Those are
|
||||
* flattened into the `{ board, boards, columns, tasks }` shape the page joins
|
||||
* by `column_id`. The totals the page shows are derived on the page from the
|
||||
* live lanes, so a drag updates them without a round-trip.
|
||||
*
|
||||
* Three writes: a card move (`PUT /boards/tasks/<id>/` with the new `column`
|
||||
* and `order`, server-validated to a sibling column of the same board), adding
|
||||
* a card (`POST /boards/columns/<id>/tasks/`, any board member), and adding a
|
||||
* column (`POST /boards/<id>/columns/`, owner/admin only). The page cannot do
|
||||
* any of these anywhere the backend wouldn't, the org, the board and the role
|
||||
* are all checked server-side; this layer only carries the request.
|
||||
*/
|
||||
import { apiRequest } from '#lib/api-helpers.js';
|
||||
|
||||
/** A board card's assignee → `{ id, name }`. ProfileSerializer nests the
|
||||
* display name under `user_details`; fall back to the email, then a label. */
|
||||
function assignee(/** @type {any} */ p) {
|
||||
const d = p.user_details || {};
|
||||
return { id: p.id, name: d.name || d.email || 'Unknown' };
|
||||
}
|
||||
|
||||
/** One nested BoardTask → the flat card shape the board renders. */
|
||||
function toCard(/** @type {any} */ t, /** @type {string} */ columnId) {
|
||||
return {
|
||||
id: t.id,
|
||||
column_id: columnId,
|
||||
order: t.order ?? 0,
|
||||
title: t.title,
|
||||
priority: t.priority,
|
||||
due_date: t.due_date ?? null,
|
||||
completed_at: t.completed_at ?? null,
|
||||
account: t.account ? { id: t.account.id, name: t.account.name } : null,
|
||||
assigned_to: (t.assigned_to || []).map(assignee)
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY = { board: null, boards: [], columns: [], tasks: [] };
|
||||
|
||||
/**
|
||||
* The active board shaped for the page. `boardId` (from `?board=`) selects which
|
||||
* board; absent or unknown falls back to the most recent one. Returns an empty
|
||||
* shape (no crash) when the org has no boards yet.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies, boardId?: string | null }} event
|
||||
*/
|
||||
export async function getBoard({ cookies, boardId = null }) {
|
||||
const listResp = await apiRequest('/boards/?archived=false', {}, { cookies });
|
||||
const results = listResp.results || [];
|
||||
if (results.length === 0) return { ...EMPTY };
|
||||
|
||||
const active =
|
||||
(boardId && results.find((/** @type {any} */ b) => b.id === boardId)) || results[0];
|
||||
const cols = await apiRequest(`/boards/${active.id}/columns/`, {}, { cookies });
|
||||
|
||||
const sorted = [...cols].sort((a, b) => a.order - b.order);
|
||||
const columns = sorted.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
order: c.order,
|
||||
limit: c.limit ?? null,
|
||||
color: c.color
|
||||
}));
|
||||
|
||||
/** @type {any[]} */
|
||||
const tasks = [];
|
||||
for (const c of sorted) {
|
||||
for (const t of c.tasks || []) tasks.push(toCard(t, c.id));
|
||||
}
|
||||
|
||||
return {
|
||||
board: { id: active.id, name: active.name, description: active.description || '' },
|
||||
boards: results.map((/** @type {any} */ b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
is_archived: b.is_archived,
|
||||
task_count: b.task_count
|
||||
})),
|
||||
columns,
|
||||
tasks,
|
||||
// Owner/admin may add or reshape columns; a plain member may not (the
|
||||
// backend enforces it. This only decides whether to *offer* the control,
|
||||
// so a member isn't shown a button that would 403). Adding a *card* needs
|
||||
// only membership, and anyone who can see this board already has it.
|
||||
canManageColumns: active.my_role === 'owner' || active.my_role === 'admin'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a column to a board. Owner/admin only, enforced by the backend
|
||||
* (`BoardColumnListCreateView`); `board` and `org` are set there from the URL
|
||||
* and the JWT, never from this body. `order` places the new lane at the end,
|
||||
* the page passes the next index, since a column defaulting to 0 would jump to
|
||||
* the front of the board.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} boardId
|
||||
* @param {{ name: string, color?: string, order?: number }} column
|
||||
*/
|
||||
export async function createColumn({ cookies }, boardId, { name, color, order }) {
|
||||
/** @type {Record<string, any>} */
|
||||
const body = { name };
|
||||
if (color) body.color = color;
|
||||
if (Number.isFinite(order)) body.order = order;
|
||||
return apiRequest(`/boards/${boardId}/columns/`, { method: 'POST', body }, { cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a board.
|
||||
*
|
||||
* `BoardListCreateView.post` derives `org`, `owner` and `created_by` from the
|
||||
* JWT, enrols the caller as the owner member, and, unless told otherwise,
|
||||
* seeds the three default columns. So the whole write surface is a name and an
|
||||
* optional description, and a fresh board is usable the moment it exists.
|
||||
*
|
||||
* Until this existed no client could create one. The page said to use the
|
||||
* mobile app, which has no board feature at all, so an org that had never been
|
||||
* given a board through the API could not get one.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {{ name: string, description?: string }} board
|
||||
*/
|
||||
export async function createBoard({ cookies }, { name, description }) {
|
||||
/** @type {Record<string, any>} */
|
||||
const body = { name };
|
||||
if (description) body.description = description;
|
||||
return apiRequest('/boards/', { method: 'POST', body }, { cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a card to a column. Any board member may; the backend derives the card's
|
||||
* `column`, `org` and `created_by` from the URL and the JWT. The write surface
|
||||
* is deliberately narrow (title, description, priority) matching
|
||||
* `BoardTaskSerializer`, so nothing here can stamp a card into another board.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} columnId
|
||||
* @param {{ title: string, description?: string, priority?: string }} card
|
||||
*/
|
||||
export async function createCard({ cookies }, columnId, { title, description, priority }) {
|
||||
/** @type {Record<string, any>} */
|
||||
const body = { title };
|
||||
if (description) body.description = description;
|
||||
if (priority) body.priority = priority;
|
||||
return apiRequest(`/boards/columns/${columnId}/tasks/`, { method: 'POST', body }, { cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a card: relocate it to `column` and place it at `order`. The backend
|
||||
* validates the column and resequences the lane, so the returned card is the
|
||||
* source of truth on the next load.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} id card id
|
||||
* @param {{ column: string, order: number }} move
|
||||
*/
|
||||
export async function moveBoardTask({ cookies }, id, { column, order }) {
|
||||
return apiRequest(
|
||||
`/boards/tasks/${id}/`,
|
||||
{ method: 'PUT', body: { column, order } },
|
||||
{ cookies }
|
||||
);
|
||||
}
|
||||
22
frontend/src/lib/server/v2/bulk-form.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export const M2M_FIELDS = new Set(['assigned_to', 'tags']);
|
||||
|
||||
/**
|
||||
* Turn the bulk form into { ids, fields }. `field` names which field to set;
|
||||
* `value` is repeatable, so an m2m field arrives as an array and a scalar as a
|
||||
* single string. A Close carries `closed_on` only when a date was picked;
|
||||
* without one the API dates it today in the org's timezone.
|
||||
* @param {FormData} formData
|
||||
*/
|
||||
export function parseBulkForm(formData) {
|
||||
const ids = formData.getAll('ids').map(String);
|
||||
const field = String(formData.get('field') ?? '');
|
||||
const values = formData.getAll('value').map(String);
|
||||
/** @type {Record<string, any>} */
|
||||
const fields = {};
|
||||
if (field) {
|
||||
fields[field] = M2M_FIELDS.has(field) ? values : (values[0] ?? '');
|
||||
}
|
||||
const closedOn = formData.get('closed_on');
|
||||
if (closedOn) fields.closed_on = String(closedOn);
|
||||
return { ids, fields };
|
||||
}
|
||||
180
frontend/src/lib/server/v2/business-hours.js
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Business hours: the wiring behind `/settings/business-hours`.
|
||||
*
|
||||
* Server-only. Reads `GET /business-hours/calendar/`, the org's default
|
||||
* calendar (created on first read if missing). This is the calendar every SLA
|
||||
* target is measured against.
|
||||
*
|
||||
* Reshape, not analytics. The backend stores the week as fourteen flat
|
||||
* TimeFields (`monday_open`/`monday_close` … `sunday_open`/`sunday_close`, a
|
||||
* null pair meaning "closed") and serializes each as "HH:MM:SS". The page reads
|
||||
* an ordered `days: [{ day, open, close }]` Monday-first, with times as "HH:MM",
|
||||
* so fold the flat fields into that array here and trim the seconds. Holidays
|
||||
* already arrive as `{ id, date, name }`.
|
||||
*
|
||||
* WRITE PATHS, AND WHO MAY USE THEM
|
||||
* `updateBusinessHours` runs the fold above backwards: `unfoldDays` turns the
|
||||
* page's `days` array back into the fourteen flat fields, Monday-first, null
|
||||
* for a closed day. It PUTs the calendar's OWN url, `/business-hours/calendar/
|
||||
* <id>/`, not the collection path this module's read uses; `BusinessCalendarView.put`
|
||||
* is `partial=True` and admin-only, and `is_default` is read-only on
|
||||
* `BusinessCalendarSerializer`, so it is never part of the outgoing body.
|
||||
* `addHoliday` and `removeHoliday` write the holiday sub-resource,
|
||||
* `/business-hours/calendar/<id>/holidays/` and its `<hid>/` detail, also
|
||||
* admin-only. A holiday add is idempotent on date: posting a date already on
|
||||
* the calendar returns the existing row with 200, not 201, and creates
|
||||
* nothing new; that is a normal outcome, not a failure to surface. A holiday
|
||||
* removal is a hard delete, the row is gone and the day counts as working
|
||||
* time again immediately. All three fail with 403 and
|
||||
* `{"error": "Only admins can update business hours."}` for a non-admin;
|
||||
* `can_edit` below only decides whether the edit controls are offered, since
|
||||
* the backend re-derives admin status from the request's own profile and is
|
||||
* what actually refuses.
|
||||
*/
|
||||
import { apiRequest } from '#lib/api-helpers.js';
|
||||
import { viewerIsAdmin } from './organization.js';
|
||||
|
||||
/** Monday-first weekday label ↔ the model's field prefix. */
|
||||
const WEEKDAYS = [
|
||||
['Monday', 'monday'],
|
||||
['Tuesday', 'tuesday'],
|
||||
['Wednesday', 'wednesday'],
|
||||
['Thursday', 'thursday'],
|
||||
['Friday', 'friday'],
|
||||
['Saturday', 'saturday'],
|
||||
['Sunday', 'sunday']
|
||||
];
|
||||
|
||||
/** "09:00:00" → "09:00"; null/"" stays null (a closed day). */
|
||||
function toHm(value) {
|
||||
return value ? value.slice(0, 5) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @returns {Promise<{ calendar: any, can_edit: boolean }>}
|
||||
*/
|
||||
export async function getBusinessHours({ cookies }) {
|
||||
const cal = await apiRequest('/business-hours/calendar/', {}, { cookies });
|
||||
return {
|
||||
calendar: {
|
||||
id: cal.id,
|
||||
name: cal.name,
|
||||
timezone: cal.timezone,
|
||||
is_default: cal.is_default,
|
||||
days: WEEKDAYS.map(([label, key]) => ({
|
||||
day: label,
|
||||
open: toHm(cal[`${key}_open`]),
|
||||
close: toHm(cal[`${key}_close`])
|
||||
})),
|
||||
holidays: (cal.holidays ?? []).map((h) => ({
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
date: h.date
|
||||
}))
|
||||
},
|
||||
// A display hint (`viewerIsAdmin`), deciding only whether
|
||||
// the page offers "Edit hours" / "Add holiday". `BusinessCalendarView.put`
|
||||
// and the holiday views re-derive admin status from `request.profile`
|
||||
// server-side, and that is what actually refuses a non-admin's write.
|
||||
can_edit: viewerIsAdmin(cookies)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the page's `days` array back into the model's fourteen flat fields.
|
||||
*
|
||||
* `getBusinessHours` above turns `monday_open` and the rest into
|
||||
* `days: [{ day, open, close }]` with "HH:MM" times; this is that in
|
||||
* reverse. A closed day is both fields null. One field null and the other
|
||||
* set stores a half-open day that the SLA clock reads as nonsense.
|
||||
* `BusinessCalendarSerializer.validate()` is the real backstop: it already
|
||||
* refuses a half-open day with a 400 (`(open_val is None) != (close_val is
|
||||
* None)`). This client-side check exists only to name the day in the
|
||||
* message, instead of surfacing the serializer's generic per-field error.
|
||||
*
|
||||
* @param {{ day: string, open: string | null, close: string | null }[]} days
|
||||
*/
|
||||
function unfoldDays(days) {
|
||||
/** @type {Record<string, string | null>} */
|
||||
const flat = {};
|
||||
for (const [label, key] of WEEKDAYS) {
|
||||
const entry = days.find((d) => d.day === label);
|
||||
const open = entry?.open || null;
|
||||
const close = entry?.close || null;
|
||||
if (Boolean(open) !== Boolean(close)) {
|
||||
throw new Error(`${label} needs both an opening and a closing time, or neither.`);
|
||||
}
|
||||
flat[`${key}_open`] = open;
|
||||
flat[`${key}_close`] = close;
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the week plus the calendar's name and timezone.
|
||||
*
|
||||
* PUTs the calendar's own detail url, not the collection path
|
||||
* `getBusinessHours` reads from; `calendar.id` survives that read's reshape,
|
||||
* so the caller always has it. `is_default` is read-only on
|
||||
* `BusinessCalendarSerializer` and is deliberately never part of the body.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} calendarId
|
||||
* @param {{ day: string, open: string | null, close: string | null }[]} days
|
||||
* @param {{ name: string, timezone: string, [key: string]: unknown }} meta caller may pass
|
||||
* extra fields (e.g. `is_default`, read off the loaded calendar); only
|
||||
* `name` and `timezone` are ever read out of it
|
||||
*/
|
||||
export async function updateBusinessHours({ cookies }, calendarId, days, meta) {
|
||||
if (!calendarId) throw new Error('Which calendar? No calendar id was given.');
|
||||
const body = { ...unfoldDays(days), name: meta.name, timezone: meta.timezone };
|
||||
return await apiRequest(
|
||||
`/business-hours/calendar/${calendarId}/`,
|
||||
{ method: 'PUT', body },
|
||||
{ cookies }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a holiday to a calendar.
|
||||
*
|
||||
* `POST /business-hours/calendar/<id>/holidays/` is idempotent on `date`:
|
||||
* adding a date already on this calendar returns the existing row with
|
||||
* HTTP 200, not 201, and creates no duplicate. That is a normal outcome, not
|
||||
* an error, so callers should not treat it as one and this module does not
|
||||
* pre-check for an existing date on the client.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} calendarId
|
||||
* @param {{ date: string, name: string }} values
|
||||
*/
|
||||
export async function addHoliday({ cookies }, calendarId, values) {
|
||||
if (!values?.date) throw new Error('A holiday needs a date.');
|
||||
const body = { date: values.date, name: values.name };
|
||||
return await apiRequest(
|
||||
`/business-hours/calendar/${calendarId}/holidays/`,
|
||||
{ method: 'POST', body },
|
||||
{ cookies }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a holiday.
|
||||
*
|
||||
* `BusinessHolidayDetailView.delete` is a hard delete: the row is gone, not
|
||||
* flagged off, and the day it named counts as working time again as soon as
|
||||
* the request succeeds.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} calendarId
|
||||
* @param {string} holidayId
|
||||
*/
|
||||
export async function removeHoliday({ cookies }, calendarId, holidayId) {
|
||||
if (!holidayId) throw new Error('Which holiday? No holiday id was given.');
|
||||
return await apiRequest(
|
||||
`/business-hours/calendar/${calendarId}/holidays/${holidayId}/`,
|
||||
{ method: 'DELETE' },
|
||||
{ cookies }
|
||||
);
|
||||
}
|
||||
104
frontend/src/lib/server/v2/business-hours.test.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const apiRequest = vi.fn();
|
||||
vi.mock('#lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
|
||||
|
||||
const { updateBusinessHours, addHoliday, removeHoliday } =
|
||||
await import('#lib/server/v2/business-hours.js');
|
||||
|
||||
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
|
||||
|
||||
const week = [
|
||||
{ day: 'Monday', open: '09:00', close: '17:00' },
|
||||
{ day: 'Tuesday', open: '09:00', close: '17:00' },
|
||||
{ day: 'Wednesday', open: '09:00', close: '17:00' },
|
||||
{ day: 'Thursday', open: '09:00', close: '17:00' },
|
||||
{ day: 'Friday', open: '09:00', close: '17:00' },
|
||||
{ day: 'Saturday', open: null, close: null },
|
||||
{ day: 'Sunday', open: null, close: null }
|
||||
];
|
||||
|
||||
describe('updateBusinessHours', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
it('unfolds the days array back into the fourteen flat fields', async () => {
|
||||
apiRequest.mockResolvedValue({});
|
||||
|
||||
await updateBusinessHours(event, 'cal1', week, { name: 'Support', timezone: 'Asia/Kolkata' });
|
||||
|
||||
const [endpoint, options] = apiRequest.mock.calls[0];
|
||||
// The detail URL, not the collection path the read uses.
|
||||
expect(endpoint).toBe('/business-hours/calendar/cal1/');
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(options.body.monday_open).toBe('09:00');
|
||||
expect(options.body.friday_close).toBe('17:00');
|
||||
// A closed day is both fields null, never one of them.
|
||||
expect(options.body.saturday_open).toBeNull();
|
||||
expect(options.body.saturday_close).toBeNull();
|
||||
expect(options.body.name).toBe('Support');
|
||||
expect(options.body.timezone).toBe('Asia/Kolkata');
|
||||
});
|
||||
|
||||
it('never sends is_default, which the serializer marks read-only', async () => {
|
||||
apiRequest.mockResolvedValue({});
|
||||
await updateBusinessHours(event, 'cal1', week, {
|
||||
name: 'Support',
|
||||
timezone: 'UTC',
|
||||
is_default: false
|
||||
});
|
||||
expect(apiRequest.mock.calls[0][1].body.is_default).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a day with an open but no close, naming the day', async () => {
|
||||
const broken = week.map((d) => (d.day === 'Wednesday' ? { ...d, close: null } : d));
|
||||
await expect(
|
||||
updateBusinessHours(event, 'cal1', broken, { name: 'S', timezone: 'UTC' })
|
||||
).rejects.toThrow(/Wednesday/);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an empty calendar id before making a request', async () => {
|
||||
await expect(
|
||||
updateBusinessHours(event, '', week, { name: 'S', timezone: 'UTC' })
|
||||
).rejects.toThrow(/calendar/i);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addHoliday', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
it('POSTs date and name to the calendar sub-resource', async () => {
|
||||
apiRequest.mockResolvedValue({ id: 'h1', date: '2026-12-25', name: 'Christmas' });
|
||||
|
||||
await addHoliday(event, 'cal1', { date: '2026-12-25', name: 'Christmas' });
|
||||
|
||||
const [endpoint, options] = apiRequest.mock.calls[0];
|
||||
expect(endpoint).toBe('/business-hours/calendar/cal1/holidays/');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toEqual({ date: '2026-12-25', name: 'Christmas' });
|
||||
});
|
||||
|
||||
it('rejects a missing date before making a request', async () => {
|
||||
await expect(addHoliday(event, 'cal1', { date: '', name: 'X' })).rejects.toThrow(/date/i);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeHoliday', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
it('DELETEs the holiday under its calendar', async () => {
|
||||
apiRequest.mockResolvedValue(null);
|
||||
await removeHoliday(event, 'cal1', 'h1');
|
||||
const [endpoint, options] = apiRequest.mock.calls[0];
|
||||
expect(endpoint).toBe('/business-hours/calendar/cal1/holidays/h1/');
|
||||
expect(options.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
48
frontend/src/lib/server/v2/calendar-feed.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Your task calendar feed: the wiring behind `/profile/calendar-feed`.
|
||||
*
|
||||
* Server-only, because the feed URL is a credential: anyone holding it can read
|
||||
* the titles and priorities of your open tasks. `/api/profile/calendar-feed/`
|
||||
* is self-scoped server-side (`profile=request.profile`) and refuses every
|
||||
* API token, so this page can only ever manage the signed-in member's own feed.
|
||||
*
|
||||
* - `GET` `{ enabled, created_at, last_used_at }`. Never the URL: the API
|
||||
* keeps only a hash of it.
|
||||
* - `POST` turns the feed on, or replaces it. The old URL stops working and
|
||||
* the response carries the new `url`, the one time it is shown.
|
||||
* - `DELETE` turns it off. The URL stops working.
|
||||
*/
|
||||
import { apiRequest } from '#lib/api-helpers.js';
|
||||
|
||||
const ENDPOINT = '/profile/calendar-feed/';
|
||||
|
||||
/**
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @returns {Promise<{ enabled: boolean, created_at: string | null, last_used_at: string | null }>}
|
||||
*/
|
||||
export async function getCalendarFeed({ cookies }) {
|
||||
const resp = await apiRequest(ENDPOINT, {}, { cookies });
|
||||
return {
|
||||
enabled: Boolean(resp?.enabled),
|
||||
created_at: resp?.created_at ?? null,
|
||||
last_used_at: resp?.last_used_at ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a new feed URL. Returns it; it cannot be read back afterwards.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function issueCalendarFeed({ cookies }) {
|
||||
const resp = await apiRequest(ENDPOINT, { method: 'POST' }, { cookies });
|
||||
return resp.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
*/
|
||||
export function disableCalendarFeed({ cookies }) {
|
||||
return apiRequest(ENDPOINT, { method: 'DELETE' }, { cookies });
|
||||
}
|
||||
55
frontend/src/lib/server/v2/calendar-feed.test.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const apiRequest = vi.fn();
|
||||
vi.mock('#lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
|
||||
|
||||
const { getCalendarFeed, issueCalendarFeed, disableCalendarFeed } =
|
||||
await import('#lib/server/v2/calendar-feed.js');
|
||||
|
||||
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
|
||||
|
||||
describe('calendar feed', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
it('reads the self-scoped endpoint and keeps only the state', async () => {
|
||||
apiRequest.mockResolvedValue({
|
||||
error: false,
|
||||
enabled: true,
|
||||
created_at: '2026-09-27T10:00:00Z',
|
||||
last_used_at: null,
|
||||
url: 'should never be here'
|
||||
});
|
||||
const feed = await getCalendarFeed(event);
|
||||
expect(apiRequest.mock.calls[0][0]).toBe('/profile/calendar-feed/');
|
||||
expect(feed).toEqual({
|
||||
enabled: true,
|
||||
created_at: '2026-09-27T10:00:00Z',
|
||||
last_used_at: null
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a disabled feed as off', async () => {
|
||||
apiRequest.mockResolvedValue({ enabled: false, created_at: null, last_used_at: null });
|
||||
expect((await getCalendarFeed(event)).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('issues with a bodiless POST and returns the one-time URL', async () => {
|
||||
apiRequest.mockResolvedValue({ enabled: true, url: 'https://api.example.com/x.ics' });
|
||||
const url = await issueCalendarFeed(event);
|
||||
const [endpoint, options] = apiRequest.mock.calls[0];
|
||||
expect(endpoint).toBe('/profile/calendar-feed/');
|
||||
expect(options).toEqual({ method: 'POST' });
|
||||
expect(url).toBe('https://api.example.com/x.ics');
|
||||
});
|
||||
|
||||
it('disables with a DELETE', async () => {
|
||||
apiRequest.mockResolvedValue({ enabled: false });
|
||||
await disableCalendarFeed(event);
|
||||
expect(apiRequest.mock.calls[0].slice(0, 2)).toEqual([
|
||||
'/profile/calendar-feed/',
|
||||
{ method: 'DELETE' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
545
frontend/src/lib/server/v2/contacts.js
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
/**
|
||||
* Contacts: the fourth v2 module wired to the real API.
|
||||
*
|
||||
* Same rules as `leads.js`, `deals.js` and `accounts.js`: this file lives under
|
||||
* `#lib/server` because SvelteKit refuses to bundle that directory into client
|
||||
* code and the access token is an httpOnly cookie. The org is never a
|
||||
* parameter; it is a claim inside the JWT and the backend reads it from there.
|
||||
*
|
||||
* WHY THIS MODULE, AND WHY NOW
|
||||
* Three wired pages already show people: a lead's converted contact, the
|
||||
* deal's contact rail, the account's people rail, and not one of them could
|
||||
* link to a person, because `/contacts/<uuid>` answered 404. The rails were
|
||||
* written as dead text for exactly that reason. Contacts was also the last
|
||||
* object in the core record graph still on fixtures: leads become an account,
|
||||
* a contact and a deal, and three quarters of that sentence was live.
|
||||
*
|
||||
* WHAT CHANGED WHEN THE FIXTURES CAME OFF
|
||||
* - `relationship` ("Champion", "Blocker") is gone for the fourth time. Contact
|
||||
* has `title` and `department`. It was a table column, a rail row, a subtitle
|
||||
* under every colleague and part of the headline sentence.
|
||||
* - `last_activity_at` is gone. Nothing records when anyone last spoke to a
|
||||
* contact: Lead has `last_contacted`, Contact has nothing like it, and there
|
||||
* is no activity table to derive one from. The list sorted by it, coloured a
|
||||
* column by it, and the detail page built its whole "gone quiet" argument on
|
||||
* it. What is real is `updated_at`, when the record was last edited, and
|
||||
* that is what the pages show, under a label that says so.
|
||||
* - `account_id` was a single account. A contact is joined to an account twice
|
||||
* over (`Contact.account`, the model's "primary", and membership of
|
||||
* `Account.contacts`) and the second is many-to-many: people here belong to
|
||||
* nought, one, two or three accounts. Both links are published now, and this
|
||||
* layer picks the primary if there is one and the first membership otherwise.
|
||||
* - `organization` survives but is demoted. It is free text and, across the
|
||||
* seeded org, routinely names a different company from the account the person
|
||||
* is actually attached to, so it is shown only where there is no account
|
||||
* link to show instead, labelled as what somebody typed.
|
||||
*
|
||||
* WHAT THE FIXTURE GOT RIGHT
|
||||
* Splitting "do not call" from "inactive": one is a rule about how you may
|
||||
* contact this person, the other is whether they still work there. Both are
|
||||
* real fields and both are still marked separately.
|
||||
*/
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { apiRequest } from '#lib/api-helpers.js';
|
||||
import { attachmentHref } from '#lib/server/v2/files.js';
|
||||
|
||||
/** See the note on FILTER_FIELDS in tickets.js. */
|
||||
export const FILTER_FIELDS = ['assigned_to', 'tags', 'city'];
|
||||
|
||||
/** DRF decimals arrive as strings. `null` stays `null`. It means "not recorded". */
|
||||
function num(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} profile
|
||||
*/
|
||||
function profileName(profile) {
|
||||
return profile?.user_details?.email || profile?.user?.email || 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which company to print, and how sure we are.
|
||||
*
|
||||
* `account_detail` is the FK the model calls primary; `linked_accounts` is
|
||||
* membership of `Account.contacts`. They are independent, so this returns the
|
||||
* primary where there is one, the first membership otherwise, and how many
|
||||
* others there are. A person attached to three accounts should not silently
|
||||
* look like they belong to one.
|
||||
*
|
||||
* @param {any} contact
|
||||
*/
|
||||
function accountLink(contact) {
|
||||
const linked = contact.linked_accounts ?? [];
|
||||
const primary = contact.account_detail ?? linked[0] ?? null;
|
||||
const others = linked.filter((/** @type {any} */ row) => row.id !== primary?.id);
|
||||
return { account: primary, other_accounts: others };
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of a Django contact the v2 pages read.
|
||||
*
|
||||
* @param {any} contact
|
||||
*/
|
||||
function toRow(contact) {
|
||||
const owners = contact.assigned_to ?? [];
|
||||
const { account, other_accounts } = accountLink(contact);
|
||||
return {
|
||||
id: contact.id,
|
||||
first_name: contact.first_name ?? '',
|
||||
last_name: contact.last_name ?? '',
|
||||
name: [contact.first_name, contact.last_name].filter(Boolean).join(' ').trim(),
|
||||
title: contact.title ?? '',
|
||||
department: contact.department ?? '',
|
||||
// Free text, and frequently not the same company as `account`.
|
||||
organization: contact.organization ?? '',
|
||||
account,
|
||||
other_accounts,
|
||||
email: contact.email ?? '',
|
||||
phone: contact.phone ?? '',
|
||||
do_not_call: Boolean(contact.do_not_call),
|
||||
linkedin_url: contact.linkedin_url ?? '',
|
||||
city: contact.city ?? '',
|
||||
state: contact.state ?? '',
|
||||
country: contact.country ?? '',
|
||||
address_line: contact.address_line ?? '',
|
||||
postcode: contact.postcode ?? '',
|
||||
description: contact.description ?? '',
|
||||
is_active: contact.is_active !== false,
|
||||
owner: owners.length ? profileName(owners[0]) : null,
|
||||
owner_count: owners.length,
|
||||
created_at: contact.created_at,
|
||||
updated_at: contact.updated_at ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* People in this org, most recently added first.
|
||||
*
|
||||
* `count` is the size of the whole filtered queryset and `results` is one page
|
||||
* of it, so the header counts the former. `active_count` / `inactive_count`
|
||||
* are counted before the active filter splits the list, which is what lets the
|
||||
* page say how many it is holding back rather than guessing from the rows it
|
||||
* happens to hold.
|
||||
*
|
||||
* The API narrows this list for non-admins to contacts they created or are
|
||||
* assigned to, so what comes back is already what this person may see.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {URLSearchParams} [params]
|
||||
*/
|
||||
export async function listContacts({ cookies }, params) {
|
||||
const query = new URLSearchParams(params ?? undefined);
|
||||
if (!query.has('limit')) query.set('limit', '25');
|
||||
|
||||
const response = await apiRequest(`/contacts/?${query}`, {}, { cookies });
|
||||
const rows = (response.results ?? []).map(toRow);
|
||||
|
||||
return {
|
||||
results: rows,
|
||||
totals: {
|
||||
count: response.count ?? rows.length,
|
||||
active: response.active_count ?? rows.length,
|
||||
inactive: response.inactive_count ?? 0,
|
||||
shown: rows.length,
|
||||
// Two facts worth surfacing on a page about reaching people: how many of
|
||||
// them you are not allowed to phone, and how many have no email at all.
|
||||
do_not_call: rows.filter((/** @type {any} */ row) => row.do_not_call).length,
|
||||
no_email: rows.filter((/** @type {any} */ row) => !row.email).length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One person, and everything that person is involved in.
|
||||
*
|
||||
* All of it arrives in a single request. `ContactDetailView.get` now returns
|
||||
* the deals and cases this contact is named on and the colleagues at their
|
||||
* account alongside the tasks it already returned. Before that, "their deals"
|
||||
* could not be asked for at all, because Contact is on the far side of a
|
||||
* many-to-many from both Opportunity and Case.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getContact({ cookies }, id) {
|
||||
const response = await fetchDetail(cookies, id);
|
||||
const contact = toRow(response.contact_obj);
|
||||
|
||||
return {
|
||||
contact,
|
||||
owners: (response.contact_obj.assigned_to ?? []).map(profileName),
|
||||
teams: (response.contact_obj.teams ?? []).map((/** @type {any} */ team) => team.name),
|
||||
deals: (response.opportunities ?? []).map((/** @type {any} */ deal) => ({
|
||||
id: deal.id,
|
||||
name: deal.name ?? '',
|
||||
stage: deal.stage,
|
||||
// Named and classified by the deal's own pipeline, never by its code.
|
||||
stage_label: deal.stage_label ?? deal.stage,
|
||||
stage_kind: deal.stage_kind ?? null,
|
||||
amount: num(deal.amount) ?? 0,
|
||||
// Priced in the deal's own currency; the API sends the org's default for
|
||||
// a deal with none. The page adds up the open ones per currency.
|
||||
currency: deal.currency || 'USD',
|
||||
closed_on: deal.closed_on ?? null
|
||||
})),
|
||||
// Computed on the server over every open deal the viewer may open. The
|
||||
// `deals` list stops at 10 rows, so totalling it undercounted.
|
||||
openDeals: {
|
||||
count: response.open_deals?.count ?? 0,
|
||||
by_currency: (response.open_deals?.by_currency ?? []).map((/** @type {any} */ row) => ({
|
||||
currency: row.currency,
|
||||
amount: num(row.amount) ?? 0
|
||||
}))
|
||||
},
|
||||
tickets: (response.cases ?? []).map((/** @type {any} */ ticket) => ({
|
||||
id: ticket.id,
|
||||
name: ticket.name ?? '',
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
created_at: ticket.created_at
|
||||
})),
|
||||
tasks: (response.tasks ?? []).map((/** @type {any} */ task) => ({
|
||||
id: task.id,
|
||||
title: task.title ?? '',
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
due_date: task.due_date ?? null
|
||||
})),
|
||||
colleagues: (response.colleagues ?? []).map((/** @type {any} */ person) => ({
|
||||
id: person.id,
|
||||
name: [person.first_name, person.last_name].filter(Boolean).join(' ').trim(),
|
||||
title: person.title ?? ''
|
||||
})),
|
||||
activity: buildContactActivity(response),
|
||||
// The API's delete rule for this caller, so Delete is offered only when it would work.
|
||||
canDelete: Boolean(response.can_delete)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The record's real events, newest first: notes, files, and creation.
|
||||
*
|
||||
* Three event KINDS, all backed by rows that exist. A note is a `Comment`, a
|
||||
* file is an `Attachments`, and the record always has its own creation. The old
|
||||
* shape read `response.comments` alone, so every file uploaded against a contact
|
||||
* was dropped from the page it should have appeared on. Nothing records status
|
||||
* changes or calls/emails/meetings on a Contact, so those are not invented here
|
||||
* (the same discipline as `leads.js`).
|
||||
*
|
||||
* `ContactDetailView.get` returns `comments` and `attachments` as siblings, and
|
||||
* an attachment's `created_by` is a bare user id rather than a nested user, so a
|
||||
* file's author is left unnamed rather than printing an id, the note author,
|
||||
* which arrives as a nested profile, is shown.
|
||||
*
|
||||
* @param {any} response
|
||||
*/
|
||||
function buildContactActivity(response) {
|
||||
/** @type {Array<{id:string,type:'note'|'file'|'status',at:string,by:string|null,body:string,href?:string|null}>} */
|
||||
const events = (response.comments ?? []).map((/** @type {any} */ c) => ({
|
||||
id: `note-${c.id}`,
|
||||
type: 'note',
|
||||
at: c.commented_on,
|
||||
by: c.commented_by?.user_details?.email || null,
|
||||
body: c.comment
|
||||
}));
|
||||
|
||||
for (const a of response.attachments ?? []) {
|
||||
events.push({
|
||||
id: `file-${a.id}`,
|
||||
type: 'file',
|
||||
at: a.created_at,
|
||||
by: null,
|
||||
body: a.file_name || 'Attachment',
|
||||
href: attachmentHref(a.id)
|
||||
});
|
||||
}
|
||||
|
||||
const contact = response.contact_obj;
|
||||
if (contact?.created_at) {
|
||||
events.push({
|
||||
id: `created-${contact.id}`,
|
||||
type: 'status',
|
||||
at: contact.created_at,
|
||||
by: null,
|
||||
body: 'Contact added'
|
||||
});
|
||||
}
|
||||
|
||||
return events.sort(
|
||||
(/** @type {any} */ a, /** @type {any} */ b) =>
|
||||
new Date(b.at).getTime() - new Date(a.at).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a note against a contact, optionally with a file. A call made, a reply,
|
||||
* a document shared.
|
||||
*
|
||||
* `ContactDetailView.post` creates a Comment scoped to the contact's org and
|
||||
* stamps `commented_by` from `request.profile`; the only field it reads from the
|
||||
* body is `comment`. It answers 403 through `assert_contact_access` for anyone
|
||||
* who is not the contact's creator, an assignee, an admin, or assigned to the
|
||||
* account the person belongs to, the same gate that governs *reading* the
|
||||
* contact, so the composer this feeds appears only to someone who could already
|
||||
* open the page, and the note is attributed server-side, never by the client.
|
||||
*
|
||||
* Unlike a lead, a contact's attachment save is a SEPARATE top-level block in
|
||||
* the view (not nested inside the comment check), so a file may ride with a note
|
||||
* or arrive on its own. The field name is `contact_attachment`.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} id
|
||||
* @param {string} comment
|
||||
* @param {File|null} [file]
|
||||
*/
|
||||
export async function addContactNote({ cookies }, id, comment, file = null) {
|
||||
if (file) {
|
||||
const form = new FormData();
|
||||
if (comment) form.set('comment', comment);
|
||||
form.set('contact_attachment', file);
|
||||
return await apiRequest(`/contacts/${id}/`, { method: 'POST', body: form }, { cookies });
|
||||
}
|
||||
return await apiRequest(`/contacts/${id}/`, { method: 'POST', body: { comment } }, { cookies });
|
||||
}
|
||||
|
||||
/** Scalar fields the contact forms own. Everything else is server-derived. */
|
||||
export const EDITABLE_FIELDS = [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'phone',
|
||||
'title',
|
||||
'department',
|
||||
'organization',
|
||||
'linkedin_url',
|
||||
'do_not_call',
|
||||
'address_line',
|
||||
'city',
|
||||
'state',
|
||||
'postcode',
|
||||
'country',
|
||||
'description',
|
||||
'account',
|
||||
'is_active'
|
||||
];
|
||||
|
||||
/**
|
||||
* The choice lists the forms need, taken from the API rather than hardcoded.
|
||||
*
|
||||
* `countries` is the same list the model validates against, so a select built
|
||||
* from it cannot offer a value the serializer will reject.
|
||||
*
|
||||
* The account picker holds up to 200 accounts. Past that it needs to become a
|
||||
* search rather than a select, and this will quietly stop offering the rest,
|
||||
* so the form says how many it is showing instead of pretending it is all of
|
||||
* them.
|
||||
*
|
||||
* @param {import('@sveltejs/kit').Cookies} cookies
|
||||
*/
|
||||
async function listChoices(cookies) {
|
||||
const [contactsResponse, accountsResponse] = await Promise.all([
|
||||
apiRequest('/contacts/?limit=1', {}, { cookies }),
|
||||
apiRequest('/accounts/?limit=200', {}, { cookies })
|
||||
]);
|
||||
const active = accountsResponse.active_accounts ?? {};
|
||||
const accounts = (active.open_accounts ?? []).map((/** @type {any} */ account) => ({
|
||||
id: account.id,
|
||||
name: account.name
|
||||
}));
|
||||
accounts.sort((/** @type {any} */ a, /** @type {any} */ b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
countries: (contactsResponse.countries ?? []).map((/** @type {any} */ pair) => ({
|
||||
value: pair[0],
|
||||
label: pair[1]
|
||||
})),
|
||||
owners: (contactsResponse.users ?? []).map((/** @type {any} */ user) => ({
|
||||
id: user.id,
|
||||
name: user.user__email
|
||||
})),
|
||||
accounts,
|
||||
account_total: active.open_accounts_count ?? accounts.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the create form needs, in one call.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string | null} [accountId] account to preselect, when arriving from one
|
||||
*/
|
||||
export async function getContactFormOptions({ cookies }, accountId = null) {
|
||||
const choices = await listChoices(cookies);
|
||||
const known = choices.accounts.some((/** @type {any} */ row) => row.id === accountId);
|
||||
return { ...choices, defaults: { country: '', account: known ? accountId : '' } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The contact plus what the edit form needs in order to explain itself.
|
||||
*
|
||||
* `form` holds only `EDITABLE_FIELDS`, so no input can bind to a
|
||||
* server-derived field by accident.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getContactForEdit({ cookies }, id) {
|
||||
const [response, choices] = await Promise.all([fetchDetail(cookies, id), listChoices(cookies)]);
|
||||
const raw = response.contact_obj;
|
||||
const contact = toRow(raw);
|
||||
|
||||
/*
|
||||
* The linked account is always an option, even when the picker does not
|
||||
* carry it: an account the caller cannot open (the list holds only the ones
|
||||
* they can), a closed one, or one past the 200 cap. A select whose value
|
||||
* matches no option shows nothing and submits nothing or its first entry, so
|
||||
* a save would unlink it. The server keeps an unchanged link whoever saves,
|
||||
* and refuses a newly chosen account the caller cannot open.
|
||||
*/
|
||||
const stored = raw.account_detail;
|
||||
const accounts =
|
||||
stored && !choices.accounts.some((/** @type {any} */ row) => row.id === stored.id)
|
||||
? [{ id: stored.id, name: stored.name }, ...choices.accounts]
|
||||
: choices.accounts;
|
||||
|
||||
return {
|
||||
contact,
|
||||
...choices,
|
||||
accounts,
|
||||
form: {
|
||||
first_name: contact.first_name,
|
||||
last_name: contact.last_name,
|
||||
email: contact.email,
|
||||
phone: contact.phone,
|
||||
title: contact.title,
|
||||
department: contact.department,
|
||||
organization: contact.organization,
|
||||
linkedin_url: contact.linkedin_url,
|
||||
do_not_call: contact.do_not_call,
|
||||
address_line: contact.address_line,
|
||||
city: contact.city,
|
||||
state: contact.state,
|
||||
postcode: contact.postcode,
|
||||
country: contact.country,
|
||||
description: contact.description,
|
||||
is_active: contact.is_active,
|
||||
// The FK only. Membership of other accounts is not this form's to edit,
|
||||
// and the form says so rather than dropping it silently.
|
||||
account: raw.account ?? '',
|
||||
// Binds to a Profile id, not a display name.
|
||||
assigned_to: raw.assigned_to?.[0]?.id ?? ''
|
||||
},
|
||||
server: {
|
||||
// Read-only context so the form can explain what it is not editing.
|
||||
owner_count: (raw.assigned_to ?? []).length,
|
||||
team_count: (raw.teams ?? []).length,
|
||||
tag_count: (raw.tags ?? []).length,
|
||||
linked_account_count: contact.other_accounts.length,
|
||||
// The API's uncapped count; the `opportunities` list stops at 10.
|
||||
deal_count: response.opportunity_count ?? (response.opportunities ?? []).length,
|
||||
ticket_count: (response.cases ?? []).length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn form values into a request body.
|
||||
*
|
||||
* Absent stays absent. A field the form did not send is a field the save
|
||||
* leaves alone, which is the whole reason for PATCH below.
|
||||
*
|
||||
* @param {Record<string, any>} values
|
||||
*/
|
||||
function toBody(values) {
|
||||
/** @type {Record<string, any>} */
|
||||
const body = {};
|
||||
for (const field of EDITABLE_FIELDS) {
|
||||
if (!(field in values)) continue;
|
||||
const value = values[field];
|
||||
body[field] = value === '' ? null : value;
|
||||
}
|
||||
if ('do_not_call' in body) body.do_not_call = Boolean(values.do_not_call);
|
||||
if ('is_active' in body) body.is_active = Boolean(values.is_active);
|
||||
// Single-select owner, so the list is empty or one long. Only present when
|
||||
// the form decided it changed. See the action for why that matters.
|
||||
if ('assigned_to' in values) {
|
||||
body.assigned_to = values.assigned_to ? [values.assigned_to] : [];
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the edit form.
|
||||
*
|
||||
* PATCH, not PUT. `ContactDetailView.put` runs `teams.clear()`,
|
||||
* `assigned_to.clear()` and `tags.clear()` unconditionally before re-adding
|
||||
* whatever the body carried, and this form carries one owner, so a single PUT
|
||||
* would drop every co-assignee, every team and every tag on the record.
|
||||
* `patch` guards each relation with `if "<field>" in data`.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} id
|
||||
* @param {Record<string, any>} values
|
||||
*/
|
||||
export async function updateContact({ cookies }, id, values) {
|
||||
return await apiRequest(
|
||||
`/contacts/${id}/`,
|
||||
{ method: 'PATCH', body: toBody(values) },
|
||||
{ cookies }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete this contact for good. The API applies the delete rule (an admin or
|
||||
* the contact's creator) and answers 404 for one the caller cannot open; the
|
||||
* page offers the action only when the detail response said `can_delete`.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function deleteContact({ cookies }, id) {
|
||||
return await apiRequest(`/contacts/${id}/`, { method: 'DELETE' }, { cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a contact.
|
||||
*
|
||||
* No `org` and no `created_by` in the body: `ContactsListView.post` sets both
|
||||
* from `request.profile`, and `CreateContactSerializer` lists neither as a
|
||||
* field. `account` must be one the caller can open, or it is refused.
|
||||
*
|
||||
* The response now carries the new `id`, which it did not before this change.
|
||||
* Without it a client cannot open what it just made.
|
||||
*
|
||||
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
|
||||
* @param {Record<string, any>} values
|
||||
*/
|
||||
export async function createContact({ cookies }, values) {
|
||||
return await apiRequest('/contacts/', { method: 'POST', body: toBody(values) }, { cookies });
|
||||
}
|
||||
|
||||
/**
|
||||
* `ContactDetailView.get` answers 404, with one body, both for a contact that
|
||||
* does not exist and for one this profile may not open (another org's, or one
|
||||
* in this org they neither created, nor are assigned to, nor reach through an
|
||||
* account they own). Deliberately not 403, which would confirm the id exists,
|
||||
* so the copy covers both cases.
|
||||
*
|
||||
* @param {import('@sveltejs/kit').Cookies} cookies
|
||||
* @param {string} id
|
||||
*/
|
||||
async function fetchDetail(cookies, id) {
|
||||
try {
|
||||
return await apiRequest(`/contacts/${id}/`, {}, { cookies });
|
||||
} catch (/** @type {any} */ err) {
|
||||
// On the status, not on the wording. Django answers a missing record with
|
||||
// "No Contact matches the given query.", no "404" in it anywhere.
|
||||
if (err?.status === 404) {
|
||||
error(404, 'That contact does not exist, or you do not have access to them.');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
144
frontend/src/lib/server/v2/contacts.test.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const apiRequest = vi.fn();
|
||||
vi.mock('#lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
|
||||
|
||||
const { getContact, getContactForEdit } = await import('#lib/server/v2/contacts.js');
|
||||
|
||||
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
|
||||
|
||||
/** Ten deal rows, as the API caps the list, plus the server's open summary. */
|
||||
function detail(openDeals) {
|
||||
return {
|
||||
contact_obj: { id: 'c1', first_name: 'Ada', last_name: 'Lovelace', assigned_to: [], teams: [] },
|
||||
opportunities: Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `d${i}`,
|
||||
name: `Deal ${i}`,
|
||||
stage: 'PROSPECTING',
|
||||
amount: '100',
|
||||
currency: 'USD'
|
||||
})),
|
||||
open_deals: openDeals,
|
||||
cases: [],
|
||||
tasks: [],
|
||||
colleagues: [],
|
||||
comments: [],
|
||||
attachments: []
|
||||
};
|
||||
}
|
||||
|
||||
describe('getContact open deals', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
it("takes the server's count and totals, not the 10 rows it holds", async () => {
|
||||
apiRequest.mockResolvedValue(
|
||||
detail({
|
||||
count: 13,
|
||||
amount: null,
|
||||
by_currency: [
|
||||
{ currency: 'EUR', count: 1, amount: '5.00' },
|
||||
{ currency: 'USD', count: 12, amount: '1110.00' }
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const { openDeals, deals } = await getContact(event, 'c1');
|
||||
|
||||
expect(deals).toHaveLength(10);
|
||||
expect(openDeals).toEqual({
|
||||
count: 13,
|
||||
by_currency: [
|
||||
{ currency: 'EUR', amount: 5 },
|
||||
{ currency: 'USD', amount: 1110 }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('reads an older server without the summary as no open deals', async () => {
|
||||
apiRequest.mockResolvedValue(detail(undefined));
|
||||
const { openDeals } = await getContact(event, 'c1');
|
||||
expect(openDeals).toEqual({ count: 0, by_currency: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContactForEdit deal count', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
/** @param {any} detailResponse */
|
||||
function respond(detailResponse) {
|
||||
apiRequest.mockImplementation(async (/** @type {string} */ url) =>
|
||||
url.startsWith('/contacts/c1/') ? detailResponse : {}
|
||||
);
|
||||
}
|
||||
|
||||
it('says how many deals there are, not how many rows came back', async () => {
|
||||
respond({ ...detail({ count: 0, by_currency: [] }), opportunity_count: 23 });
|
||||
const { server } = await getContactForEdit(event, 'c1');
|
||||
expect(server.deal_count).toBe(23);
|
||||
});
|
||||
|
||||
it('falls back to the rows from an older server without the count', async () => {
|
||||
respond(detail({ count: 0, by_currency: [] }));
|
||||
const { server } = await getContactForEdit(event, 'c1');
|
||||
expect(server.deal_count).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContactForEdit account picker', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
/** @param {any} contactObj */
|
||||
function respond(contactObj) {
|
||||
apiRequest.mockImplementation(async (/** @type {string} */ url) => {
|
||||
if (url.startsWith('/contacts/c1/'))
|
||||
return { ...detail({ count: 0, by_currency: [] }), contact_obj: contactObj };
|
||||
if (url.startsWith('/accounts/'))
|
||||
return {
|
||||
active_accounts: {
|
||||
open_accounts: [{ id: 'a-visible', name: 'Visible Co' }],
|
||||
open_accounts_count: 1
|
||||
}
|
||||
};
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
const base = { id: 'c1', first_name: 'Ada', last_name: 'Lovelace', assigned_to: [], teams: [] };
|
||||
|
||||
it('offers a linked account the picker list does not carry, so a save keeps it', async () => {
|
||||
respond({
|
||||
...base,
|
||||
account: 'a-hidden',
|
||||
account_detail: { id: 'a-hidden', name: 'Hidden Holdings' }
|
||||
});
|
||||
const { accounts, form } = await getContactForEdit(event, 'c1');
|
||||
expect(form.account).toBe('a-hidden');
|
||||
expect(accounts).toEqual([
|
||||
{ id: 'a-hidden', name: 'Hidden Holdings' },
|
||||
{ id: 'a-visible', name: 'Visible Co' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not repeat a linked account the list already carries', async () => {
|
||||
respond({
|
||||
...base,
|
||||
account: 'a-visible',
|
||||
account_detail: { id: 'a-visible', name: 'Visible Co' }
|
||||
});
|
||||
const { accounts } = await getContactForEdit(event, 'c1');
|
||||
expect(accounts).toEqual([{ id: 'a-visible', name: 'Visible Co' }]);
|
||||
});
|
||||
|
||||
it('offers only the list when the contact has no account', async () => {
|
||||
respond({ ...base, account: null, account_detail: null });
|
||||
const { accounts, form } = await getContactForEdit(event, 'c1');
|
||||
expect(form.account).toBe('');
|
||||
expect(accounts).toEqual([{ id: 'a-visible', name: 'Visible Co' }]);
|
||||
});
|
||||
});
|
||||
43
frontend/src/lib/server/v2/csat.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* The rating a customer clicked in the survey email.
|
||||
*
|
||||
* The email renders one star per point on the scale, each an ordinary link to
|
||||
* `/csat/<token>?rating=N`. This reads that N so the page can open with the
|
||||
* star already lit and ask only whether they want to add a comment.
|
||||
*
|
||||
* It PRE-SELECTS and nothing more. A link is a GET, and mail scanners,
|
||||
* corporate link-rewriters and preview bots all follow GETs before a human
|
||||
* sees the message, so treating the parameter as an answer would let anything
|
||||
* in the delivery path fill in the survey. The rating is recorded by the form
|
||||
* POST on the page, never by arriving at it.
|
||||
*
|
||||
* Lives here rather than in `+page.server.js` so vitest can reach it: that
|
||||
* harness resolves `#lib` and `$app/env/*` and nothing else, so a
|
||||
* route module importing `$app/forms` for its actions cannot be loaded at all.
|
||||
* See the docstring in `vitest.config.js`, and `support.js` for the same move.
|
||||
*/
|
||||
|
||||
// Mirrors CSAT_RATING_MIN / CSAT_RATING_MAX in backend/cases/tasks.py. The
|
||||
// backend is the boundary that matters: it re-validates the POST against these
|
||||
// same bounds, so a mismatch here degrades the pre-selection and cannot widen
|
||||
// what is accepted.
|
||||
export const RATING_MIN = 1;
|
||||
export const RATING_MAX = 5;
|
||||
|
||||
/**
|
||||
* @param {URL} url
|
||||
* @returns {number | null} the clicked rating, or null when there wasn't one
|
||||
*/
|
||||
export function preselectedRating(url) {
|
||||
const raw = url.searchParams.get('rating');
|
||||
if (raw === null) return null;
|
||||
|
||||
// Number('') is 0 and Number(' 3 ') is 3, so neither an empty parameter nor
|
||||
// a padded one can be trusted to the range check alone. Require the digits
|
||||
// and nothing else.
|
||||
if (!/^\d+$/.test(raw)) return null;
|
||||
|
||||
const value = Number(raw);
|
||||
if (value < RATING_MIN || value > RATING_MAX) return null;
|
||||
return value;
|
||||
}
|
||||