Convert frontend to React JS and add S3 deployment workflows
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 33s
Deploy Frontend Beta (S3) / deploy-frontend (push) Successful in 20s

This commit is contained in:
vickytechkey 2026-10-24 14:33:11 +05:30
parent c957cc295f
commit e10f9829ee
649 changed files with 4562 additions and 102340 deletions

View file

@ -0,0 +1,39 @@
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

View file

@ -0,0 +1,39 @@
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
View file

@ -57,11 +57,9 @@ site/
# Node.js / Frontend
frontend/node_modules/
frontend/.svelte-kit/
frontend/build/
frontend/dist/
frontend/.env
frontend/.env.local
frontend/package-lock.json
# Logs
logs/

View file

@ -1,30 +0,0 @@
# 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

View file

@ -1,29 +1 @@
# 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
VITE_API_BASE_URL=https://betaapicsa.tradhox.com/api

48
frontend/.gitignore vendored
View file

@ -1,30 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
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
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -1 +0,0 @@
engine-strict=true

8
frontend/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

View file

@ -1,14 +0,0 @@
# 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

View file

@ -1,16 +0,0 @@
{
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View file

@ -1,87 +0,0 @@
# 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"]

View file

@ -1,41 +1,16 @@
# BottleCRM web app
# React + Vite
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).
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
## Run it locally
Currently, two official plugins are available:
Start the backend first (see [Development setup](../docs/contributing/development-setup.md)), then:
- [@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/)
```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
```
## React Compiler
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.
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).
## Checks
## Expanding the Oxlint configuration
```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)
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.

View file

@ -1,16 +0,0 @@
{
"$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
}

View file

@ -1,31 +0,0 @@
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'
}
}
];

16
frontend/index.html Normal file
View file

@ -0,0 +1,16 @@
<!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>

View file

@ -1,11 +0,0 @@
{
"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 Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,57 +1,26 @@
{
"name": "svelte-crm",
"name": "crmfrontend",
"private": true,
"version": "0.0.1",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev": "vite",
"build": "vite build",
"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": {
"@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"
"lint": "oxlint",
"preview": "vite preview"
},
"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"
"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"
},
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
"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"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,14 +0,0 @@
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'

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<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>

After

Width:  |  Height:  |  Size: 4.9 KiB

42
frontend/run.sh Normal file
View file

@ -0,0 +1,42 @@
#!/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

78
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,78 @@
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;

File diff suppressed because it is too large Load diff

32
frontend/src/app.d.ts vendored
View file

@ -1,32 +0,0 @@
// 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 {};

View file

@ -1,30 +0,0 @@
<!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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,128 @@
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)}>&times;</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;

View file

@ -0,0 +1,47 @@
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;

View file

@ -0,0 +1,56 @@
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;

View file

@ -0,0 +1,35 @@
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;

3
frontend/src/config.js Normal file
View file

@ -0,0 +1,3 @@
// DigiHox CRM - Frontend Configuration
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "https://betaapicsa.tradhox.com/api";

View file

@ -0,0 +1,109 @@
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);
};

View file

@ -1,33 +0,0 @@
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 }
});

View file

@ -1,16 +0,0 @@
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();

View file

@ -1,396 +0,0 @@
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);
});

View file

@ -1,180 +0,0 @@
/**
* 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);
});
});

422
frontend/src/index.css Normal file
View file

@ -0,0 +1,422 @@
/* 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; }
}

View file

@ -1,19 +0,0 @@
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
}
});

View file

@ -1,23 +0,0 @@
/**
* 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;
}

View file

@ -1,30 +0,0 @@
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);
});
});

View file

@ -1,183 +0,0 @@
/**
* 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;
}
}

View file

@ -1,98 +0,0 @@
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();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

View file

@ -1,9 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1,112 +0,0 @@
<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>

View file

@ -1,60 +0,0 @@
<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>

View file

@ -1,651 +0,0 @@
<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>

View file

@ -1,64 +0,0 @@
<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>

View file

@ -1,453 +0,0 @@
<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>

View file

@ -1,115 +0,0 @@
<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}

View file

@ -1,9 +0,0 @@
import Root, { buttonVariants } from './button.svelte';
export {
Root,
//
Root as Button,
buttonVariants
};

View file

@ -1,7 +0,0 @@
<script>
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps } = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View file

@ -1,38 +0,0 @@
<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>

View file

@ -1,13 +0,0 @@
<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}
/>

View file

@ -1,13 +0,0 @@
<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>

View file

@ -1,14 +0,0 @@
<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>

View file

@ -1,16 +0,0 @@
<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}
/>

View file

@ -1,7 +0,0 @@
<script>
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ...restProps } = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View file

@ -1,13 +0,0 @@
<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}
/>

View file

@ -1,7 +0,0 @@
<script>
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps } = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View file

@ -1,7 +0,0 @@
<script>
import { Dialog as DialogPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps } = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View file

@ -1,34 +0,0 @@
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
};

View file

@ -1,263 +0,0 @@
/**
* 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;
}

View file

@ -1,130 +0,0 @@
/**
* 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'
};

View file

@ -1,105 +0,0 @@
/**
* 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'
]
}
};

View file

@ -1,132 +0,0 @@
/**
* 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');
}

View file

@ -1,300 +0,0 @@
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);
});
});

View file

@ -1,185 +0,0 @@
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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');
}

View file

@ -1,163 +0,0 @@
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/&lt;x&gt;&amp;</loc>');
});
});

View file

@ -1,41 +0,0 @@
/**
* 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(' ');
}

View file

@ -1,94 +0,0 @@
/**
* 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) }
);
}

View file

@ -1,137 +0,0 @@
/**
* 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: '/' });
}

View file

@ -1,219 +0,0 @@
/**
* 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);
});
});

View file

@ -1,54 +0,0 @@
/**
* 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') ?? ''
}
: {})
};
}

View file

@ -1,71 +0,0 @@
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({});
});
});

View file

@ -1,137 +0,0 @@
/**
* 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"}');
});
});

View file

@ -1,70 +0,0 @@
/**
* 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;
}
}

View file

@ -1,110 +0,0 @@
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();
}
);
});

View file

@ -1,536 +0,0 @@
/**
* 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;
}
}

View file

@ -1,82 +0,0 @@
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']);
});
});

View file

@ -1,183 +0,0 @@
/**
* 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 });
}

View file

@ -1,81 +0,0 @@
/**
* 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 ?? []
};
}

View file

@ -1,67 +0,0 @@
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');
});
});

View file

@ -1,296 +0,0 @@
/**
* 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 };
}

View file

@ -1,324 +0,0 @@
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');
});
});

View file

@ -1,167 +0,0 @@
/**
* 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 }
);
}

View file

@ -1,22 +0,0 @@
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 };
}

View file

@ -1,180 +0,0 @@
/**
* 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 }
);
}

View file

@ -1,104 +0,0 @@
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');
});
});

View file

@ -1,48 +0,0 @@
/**
* 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 });
}

View file

@ -1,55 +0,0 @@
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' }
]);
});
});

View file

@ -1,545 +0,0 @@
/**
* 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;
}
}

View file

@ -1,144 +0,0 @@
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' }]);
});
});

View file

@ -1,43 +0,0 @@
/**
* 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;
}

Some files were not shown because too many files have changed in this diff Show more