feat: implement tag creation functionality with error handling in settings

feat: enhance tag management UI with form for adding new tags

feat: add approval rule management with create, update, deactivate, and delete actions

feat: improve ticket approval settings UI with form handling and error messages

feat: add timer management functionality in timesheet with stop timer action

chore: add Vitest configuration for server-side testing

fix: update development API URL in mobile configuration
This commit is contained in:
Ashwin 2026-08-04 15:13:35 +05:30
parent 24390478e0
commit 86e13796fe
61 changed files with 7392 additions and 502 deletions

View file

@ -123,6 +123,10 @@ jobs:
working-directory: frontend
run: pnpm check
- name: Run tests
working-directory: frontend
run: pnpm test
- name: Build
working-directory: frontend
run: pnpm build

View file

@ -11,15 +11,17 @@
"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 ."
"lint": "prettier --check . && eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.27.0",
"@internationalized/date": "^3.12.3",
"@lucide/svelte": "^1.28.0",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.1",
"@sveltejs/kit": "^2.70.2",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
@ -28,26 +30,27 @@
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.22.0",
"globals": "^17.7.0",
"globals": "^17.9.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"svelte": "^5.56.8",
"svelte-check": "^4.7.3",
"svelte-check": "^4.7.4",
"svelte-sonner": "^1.1.1",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.1.5"
"vite": "^8.2.0",
"vitest": "^4.1.10"
},
"dependencies": {
"@sentry/sveltekit": "^10.68.0",
"axios": "^1.18.1",
"@sentry/sveltekit": "^10.69.0",
"axios": "^1.19.0",
"clsx": "^2.1.1",
"libphonenumber-js": "^1.13.9",
"svelte-dnd-action": "^0.9.74",
"libphonenumber-js": "^1.13.10",
"svelte-dnd-action": "^0.9.77",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwind-variants": "^3.3.0",
"tw-animate-css": "^1.4.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -2,3 +2,8 @@ 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'

View file

@ -12,11 +12,27 @@
* so fold the flat fields into that array here and trim the seconds. Holidays
* already arrive as `{ id, date, name }`.
*
* Read-only page: "Edit hours" / "Add holiday" are deferred builders (the
* backend has admin PUT/POST/DELETE endpoints, but no form is wired yet), so
* there is no write path here.
* 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 { viewerRole } from './organization.js';
/** Monday-first weekday label ↔ the model's field prefix. */
const WEEKDAYS = [
@ -36,7 +52,7 @@ function toHm(value) {
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ calendar: any }>}
* @returns {Promise<{ calendar: any, can_edit: boolean }>}
*/
export async function getBusinessHours({ cookies }) {
const cal = await apiRequest('/business-hours/calendar/', {}, { cookies });
@ -56,6 +72,109 @@ export async function getBusinessHours({ cookies }) {
name: h.name,
date: h.date
}))
}
},
// A display hint decoded from the JWT `role` claim, 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: viewerRole(cookies) === 'ADMIN'
};
}
/**
* 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

@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { updateBusinessHours, addHoliday, removeHoliday } =
await import('$lib/server/v2/business-hours.js');
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
const week = [
{ day: 'Monday', open: '09:00', close: '17:00' },
{ day: 'Tuesday', open: '09:00', close: '17:00' },
{ day: 'Wednesday', open: '09:00', close: '17:00' },
{ day: 'Thursday', open: '09:00', close: '17:00' },
{ day: 'Friday', open: '09:00', close: '17:00' },
{ day: 'Saturday', open: null, close: null },
{ day: 'Sunday', open: null, close: null }
];
describe('updateBusinessHours', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('unfolds the days array back into the fourteen flat fields', async () => {
apiRequest.mockResolvedValue({});
await updateBusinessHours(event, 'cal1', week, { name: 'Support', timezone: 'Asia/Kolkata' });
const [endpoint, options] = apiRequest.mock.calls[0];
// The detail URL, not the collection path the read uses.
expect(endpoint).toBe('/business-hours/calendar/cal1/');
expect(options.method).toBe('PUT');
expect(options.body.monday_open).toBe('09:00');
expect(options.body.friday_close).toBe('17:00');
// A closed day is both fields null, never one of them.
expect(options.body.saturday_open).toBeNull();
expect(options.body.saturday_close).toBeNull();
expect(options.body.name).toBe('Support');
expect(options.body.timezone).toBe('Asia/Kolkata');
});
it('never sends is_default, which the serializer marks read-only', async () => {
apiRequest.mockResolvedValue({});
await updateBusinessHours(event, 'cal1', week, {
name: 'Support',
timezone: 'UTC',
is_default: false
});
expect(apiRequest.mock.calls[0][1].body.is_default).toBeUndefined();
});
it('rejects a day with an open but no close, naming the day', async () => {
const broken = week.map((d) => (d.day === 'Wednesday' ? { ...d, close: null } : d));
await expect(
updateBusinessHours(event, 'cal1', broken, { name: 'S', timezone: 'UTC' })
).rejects.toThrow(/Wednesday/);
expect(apiRequest).not.toHaveBeenCalled();
});
it('refuses an empty calendar id before making a request', async () => {
await expect(
updateBusinessHours(event, '', week, { name: 'S', timezone: 'UTC' })
).rejects.toThrow(/calendar/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('addHoliday', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('POSTs date and name to the calendar sub-resource', async () => {
apiRequest.mockResolvedValue({ id: 'h1', date: '2026-12-25', name: 'Christmas' });
await addHoliday(event, 'cal1', { date: '2026-12-25', name: 'Christmas' });
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/business-hours/calendar/cal1/holidays/');
expect(options.method).toBe('POST');
expect(options.body).toEqual({ date: '2026-12-25', name: 'Christmas' });
});
it('rejects a missing date before making a request', async () => {
await expect(addHoliday(event, 'cal1', { date: '', name: 'X' })).rejects.toThrow(/date/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('removeHoliday', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('DELETEs the holiday under its calendar', async () => {
apiRequest.mockResolvedValue(null);
await removeHoliday(event, 'cal1', 'h1');
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/business-hours/calendar/cal1/holidays/h1/');
expect(options.method).toBe('DELETE');
});
});

View file

@ -8,15 +8,26 @@
* `totals` block { count, active, models_extended, required_with_gaps } for the
* stat cards. Both are org-scoped server-side; the page never counts records.
*
* Read-only page: "New field" is a deferred builder, and the rows have no
* inline edit/toggle controls. No write path here. The page groups by
* target_model and orders by display_order itself.
* Create, edit, turn off and turn back on are all wired below. `key`,
* `target_model` and `field_type` are only ever sent on create: values live in
* each record's `custom_fields` JSON keyed by `key` (see the module docstring
* in `backend/common/custom_fields.py`), so changing `key` orphans them,
* changing `target_model` leaves them on the old entity, and changing
* `field_type` reinterprets values written under the old one.
* `CustomFieldDefinitionSerializer.validate()` freezes all three server-side
* too; this module just avoids offering the doomed edit in the first place.
* "Turn off" is `DELETE /custom-fields/<id>/`, a soft delete
* (`CustomFieldDefinitionDetailView.delete` flips `is_active`, never removes
* the row or the values on records that already carry it). "Turn on" is the
* same `updateCustomField` PUT with only `is_active: true`, since the
* detail view has no separate reactivate endpoint.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { viewerRole } from './organization.js';
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ fields: any[], totals: any }>}
* @returns {Promise<{ fields: any[], totals: any, can_edit: boolean }>}
*/
export async function getCustomFields({ cookies }) {
const resp = await apiRequest('/custom-fields/', {}, { cookies });
@ -27,6 +38,113 @@ export async function getCustomFields({ cookies }) {
active: 0,
models_extended: 0,
required_with_gaps: 0
}
},
can_edit: viewerRole(cookies) === 'ADMIN'
};
}
/** The fields the serializer accepts on create. `org` is set from the JWT
* server-side and `records_missing_value` is computed, so neither appears. */
const CREATE_FIELDS = [
'target_model',
'key',
'label',
'field_type',
'is_required',
'is_filterable',
'display_order',
'is_active'
];
/** Edit omits the three fields `CustomFieldDefinitionSerializer.validate()`
* freezes after creation. Values live in each record's `custom_fields` JSON
* keyed by `key` (see `backend/common/custom_fields.py`): changing `key`
* orphans them, changing `target_model` leaves them on the old entity, and
* changing `field_type` reinterprets values written under the old one. The
* backend refuses all three with a 400; not offering them is better than
* teaching it through a failed save. */
const FROZEN_AFTER_CREATE = ['key', 'target_model', 'field_type'];
const UPDATE_FIELDS = CREATE_FIELDS.filter((f) => !FROZEN_AFTER_CREATE.includes(f));
/** "Very High" to "very-high". Only ever applied to a NEW dropdown option: an
* existing option's value is stored on every record that uses it and must
* survive a relabel untouched. */
function slugifyOptionValue(label) {
return label
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Shape the request body from an allow-list.
*
* @param {string[]} allowed
* @param {{ [key: string]: any }} values
*/
function buildBody(allowed, values) {
/** @type {Record<string, any>} */
const body = {};
for (const field of allowed) {
if (values[field] === undefined) continue;
body[field] = values[field];
}
if (body.display_order !== undefined) body.display_order = Number(body.display_order) || 0;
for (const flag of ['is_required', 'is_filterable', 'is_active']) {
if (body[flag] !== undefined) body[flag] = Boolean(body[flag]);
}
// `options` is only legal on a dropdown. `validate_definition_options`
// rejects a non-empty options list on any other type, so this is not
// defensive noise: sending it turns a valid text field into a 400.
if (values.field_type === 'dropdown') {
const rows = (values.options ?? [])
.map((/** @type {any} */ o) => ({
value: o.value ? String(o.value) : slugifyOptionValue(String(o.label ?? '')),
label: String(o.label ?? '').trim()
}))
.filter((/** @type {any} */ o) => o.label && o.value);
if (!rows.length) {
throw new Error('A dropdown needs at least one option.');
}
const seen = new Set();
for (const row of rows) {
if (seen.has(row.value)) {
throw new Error(`Two options would both be stored as "${row.value}". Rename one.`);
}
seen.add(row.value);
}
body.options = rows;
}
return body;
}
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function createCustomField({ cookies }, values) {
const body = buildBody(CREATE_FIELDS, values);
return await apiRequest('/custom-fields/', { method: 'POST', body }, { cookies });
}
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function updateCustomField({ cookies }, id, values) {
if (!id) throw new Error('Which field? No field id was given.');
const body = buildBody(UPDATE_FIELDS, values);
return await apiRequest(`/custom-fields/${id}/`, { method: 'PUT', body }, { cookies });
}
/**
* Turn a field off.
*
* `CustomFieldDefinitionDetailView.delete` is a soft delete: it flips
* `is_active` and leaves every stored value readable. The UI says "Turn off"
* for that reason; calling it Delete would promise something the backend does
* not do.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export async function deactivateCustomField({ cookies }, id) {
if (!id) throw new Error('Which field? No field id was given.');
return await apiRequest(`/custom-fields/${id}/`, { method: 'DELETE' }, { cookies });
}

View file

@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { createCustomField, updateCustomField, deactivateCustomField } =
await import('$lib/server/v2/custom-fields.js');
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
const base = {
target_model: 'Case',
key: 'severity',
label: 'Severity',
field_type: 'text',
is_required: false,
is_filterable: true,
display_order: 3
};
describe('createCustomField', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('POSTs the definition to /custom-fields/', async () => {
apiRequest.mockResolvedValue({ id: 'cf1', ...base });
const result = await createCustomField(event, base);
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/custom-fields/');
expect(options.method).toBe('POST');
expect(options.body.key).toBe('severity');
expect(options.body.target_model).toBe('Case');
expect(options.body.display_order).toBe(3);
expect(result.id).toBe('cf1');
});
it('omits options entirely for a non-dropdown field', async () => {
// `validate_definition_options` rejects a non-empty `options` on any type
// other than dropdown, so sending [] or null on a text field is not
// harmless noise, it is the difference between 201 and 400.
apiRequest.mockResolvedValue({});
await createCustomField(event, {
...base,
field_type: 'text',
options: [{ value: 'a', label: 'A' }]
});
expect(apiRequest.mock.calls[0][1].body.options).toBeUndefined();
});
it('sends dropdown options as {value,label} pairs', async () => {
apiRequest.mockResolvedValue({});
await createCustomField(event, {
...base,
field_type: 'dropdown',
options: [
{ value: '', label: 'Very High' },
{ value: 'low', label: 'Low' }
]
});
// A blank value is a new row; the label is slugified into one. An
// existing value is preserved verbatim, because the stored values on
// every record reference it.
expect(apiRequest.mock.calls[0][1].body.options).toEqual([
{ value: 'very-high', label: 'Very High' },
{ value: 'low', label: 'Low' }
]);
});
it('rejects a dropdown with no options before making a request', async () => {
await expect(
createCustomField(event, { ...base, field_type: 'dropdown', options: [] })
).rejects.toThrow(/at least one option/i);
expect(apiRequest).not.toHaveBeenCalled();
});
it('never forwards a client-supplied org', async () => {
apiRequest.mockResolvedValue({});
await createCustomField(event, { ...base, org: 'attacker-org' });
expect(apiRequest.mock.calls[0][1].body.org).toBeUndefined();
});
});
describe('updateCustomField', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('PUTs to the detail endpoint without key, target_model or field_type', async () => {
// Values live on each record keyed by `key`, and every stored value for a
// field was written and checked against its `field_type`.
// `CustomFieldDefinitionSerializer.validate()` freezes all three
// server-side (a differing value in the PUT body is a 400); this layer is
// what refuses to send any of them in the first place. `field_type` here
// changes to `number`, not `dropdown`, on purpose: a `dropdown` value
// would route through the options-shaping branch, which is a different
// behaviour covered below.
apiRequest.mockResolvedValue({});
await updateCustomField(event, 'cf1', {
...base,
key: 'renamed',
target_model: 'Lead',
field_type: 'number'
});
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/custom-fields/cf1/');
expect(options.method).toBe('PUT');
expect(options.body.key).toBeUndefined();
expect(options.body.target_model).toBeUndefined();
expect(options.body.field_type).toBeUndefined();
expect(options.body.label).toBe('Severity');
});
it('still attaches options when editing an existing dropdown field', async () => {
// `field_type` never reaches the outgoing body (it's frozen), but the
// caller still has to say "this definition is a dropdown" so `buildBody`
// knows to shape and attach `options`. Losing that signal on edit would
// silently drop a dropdown's choices from every save.
apiRequest.mockResolvedValue({});
await updateCustomField(event, 'cf1', {
...base,
field_type: 'dropdown',
options: [{ value: 'low', label: 'Low' }]
});
const [, options] = apiRequest.mock.calls[0];
expect(options.body.field_type).toBeUndefined();
expect(options.body.options).toEqual([{ value: 'low', label: 'Low' }]);
});
it('supports a minimal reactivation body of just is_active', async () => {
// The "Turn on" control posts only an id; its action calls this with
// `{ is_active: true }` and nothing else, deliberately bypassing the
// full-form field reader so a field the form never submitted can't read
// back as an empty string and blank something out (see the `activate`
// action's comment in +page.server.js).
apiRequest.mockResolvedValue({});
await updateCustomField(event, 'cf1', { is_active: true });
const [, options] = apiRequest.mock.calls[0];
expect(options.body).toEqual({ is_active: true });
});
});
describe('deactivateCustomField', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('DELETEs the detail endpoint', async () => {
apiRequest.mockResolvedValue({ error: false, message: 'Custom field deactivated' });
await deactivateCustomField(event, 'cf1');
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/custom-fields/cf1/');
expect(options.method).toBe('DELETE');
});
it('refuses an empty id before making a request', async () => {
await expect(deactivateCustomField(event, '')).rejects.toThrow(/which/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});

View file

@ -49,6 +49,7 @@
*/
import { apiRequest } from '$lib/api-helpers.js';
import { env } from '$env/dynamic/public';
import { getOrgPeopleAndTeams } from './org-people.js';
const API_BASE_URL = `${env.PUBLIC_DJANGO_API_URL}/api`;
@ -173,32 +174,6 @@ export async function listDocuments({ cookies }) {
return { results, totals: computeTotals(results) };
}
/**
* In-org people and teams for the share pickers.
* `/users/get-teams-and-users/` filters both to `request.profile.org`, so the
* picker can only ever offer in-org targets, the same boundary the upload/edit
* view enforces when it filters `shared_to`/`teams` by org, surfaced early as
* UX.
*
* @param {import('@sveltejs/kit').Cookies} cookies
*/
async function shareOptions(cookies) {
try {
const resp = await apiRequest('/users/get-teams-and-users/', {}, { cookies });
const people = (resp?.profiles ?? []).map((/** @type {any} */ p) => ({
id: p.id,
name: personName(p)
}));
const teams = (resp?.teams ?? []).map((/** @type {any} */ t) => ({
id: t.id,
name: t.name
}));
return { people, teams };
} catch {
return { people: [], teams: [] };
}
}
/**
* The share pickers for the upload form.
*
@ -212,7 +187,7 @@ async function shareOptions(cookies) {
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export async function getUploadOptions({ cookies }) {
const { people, teams } = await shareOptions(cookies);
const { people, teams } = await getOrgPeopleAndTeams(cookies);
return { people, teams };
}
@ -233,7 +208,7 @@ export async function getDocumentForEdit({ cookies }, id) {
if (!doc.can_write) return { can_edit: false };
const { people, teams } = await shareOptions(cookies);
const { people, teams } = await getOrgPeopleAndTeams(cookies);
return {
can_edit: true,
people,

View file

@ -3,19 +3,27 @@
*
* Server-only. Reads `GET /cases/escalation-policies/`, at most one row per
* priority. Each row carries the policy's two halves (first_response /
* resolution action + target), the team to notify, and. The reason the page
* exists; `breaches_last_30d`, a server-side count of SLA breaches per
* resolution action + target), the team to notify, and the reason the page
* exists: `breaches_last_30d`, a server-side count of SLA breaches per
* priority over the last 30 days. That count is condition-based, not sourced
* from the escalation event log, so it still surfaces breaches under a dead
* policy (off, or reassigning to nobody), which is the page's whole point.
*
* Read-only page: "Edit policy" is a deferred builder, so there is no write
* path to wire here. The backend returns targets as full profile objects
* (`user_details.name`) and the team as a full team object; the page only wants
* `{ id, name }`, so flatten to that here rather than teach the component the
* backend's nesting.
* The backend returns targets as full profile objects (`user_details.name`)
* and the team as a full team object; the page only wants `{ id, name }`, so
* flatten to that here rather than teach the component the backend's nesting.
* The same flattened shape is what the edit form uses to preselect a target.
*
* Create, edit, turn off, turn on and delete are wired below. The picker
* lists (`people`, `teams`) are NOT fetched here: `settings.js`'s
* `getSettingsHub` calls `getEscalationPolicies` and reads only `.policies`
* off it, so a picker fetch in this function would be a redundant request on
* every settings-hub load. The route's `load` fetches `getOrgPeopleAndTeams`
* itself, in parallel with this.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { ESCALATION_PRIORITIES } from '$lib/v2/enums.js';
import { viewerRole } from './organization.js';
/** Profile → the `{ id, name }` the card renders, or null when unset. */
function shapeTarget(target) {
@ -31,7 +39,7 @@ function shapeTeam(team) {
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ policies: any[] }>}
* @returns {Promise<{ policies: any[], can_edit: boolean }>}
*/
export async function getEscalationPolicies({ cookies }) {
const { policies } = await apiRequest('/cases/escalation-policies/', {}, { cookies });
@ -46,6 +54,75 @@ export async function getEscalationPolicies({ cookies }) {
resolution_target: shapeTarget(p.resolution_target),
notify_team: shapeTeam(p.notify_team),
breaches_last_30d: p.breaches_last_30d ?? { first_response: 0, resolution: 0 }
}))
})),
// A display hint: POST/PUT/DELETE on `/cases/escalation-policies/` each
// start with `_is_admin(request.profile)` server-side and 403 regardless
// of what this says. This only decides whether the page offers the
// controls.
can_edit: viewerRole(cookies) === 'ADMIN'
};
}
export const CREATE_FIELDS = [
'priority',
'first_response_action',
'resolution_action',
'first_response_target_id',
'resolution_target_id',
'notify_team_id',
'is_active'
];
/** `priority` is the natural key and is frozen after create. It is not merely
* unwise to change: `EscalationPolicyDetailView.put` deletes the key from the
* body before the serializer sees it, so a PUT carrying a new priority
* returns 200 and changes nothing. Omitting it here means the page never
* offers a control whose success is a lie. */
export const UPDATE_FIELDS = CREATE_FIELDS.filter((f) => f !== 'priority');
/** @param {string[]} allowed @param {{ [key: string]: any }} values */
function buildBody(allowed, values) {
/** @type {Record<string, any>} */
const body = {};
for (const field of allowed) {
if (values[field] === undefined) continue;
body[field] = values[field];
}
for (const nullable of ['first_response_target_id', 'resolution_target_id', 'notify_team_id']) {
if (body[nullable] === '') body[nullable] = null;
}
if (body.is_active !== undefined) body.is_active = Boolean(body.is_active);
return body;
}
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function createEscalationPolicy({ cookies }, values) {
const body = buildBody(CREATE_FIELDS, values);
if (!ESCALATION_PRIORITIES.includes(body.priority)) {
throw new Error('Pick a priority for the policy.');
}
return await apiRequest('/cases/escalation-policies/', { method: 'POST', body }, { cookies });
}
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function updateEscalationPolicy({ cookies }, id, values) {
if (!id) throw new Error('Which policy? No policy id was given.');
const body = buildBody(UPDATE_FIELDS, values);
return await apiRequest(
`/cases/escalation-policies/${id}/`,
{ method: 'PUT', body },
{ cookies }
);
}
/**
* Delete a policy, permanently. `EscalationPolicyDetailView.delete` calls
* `obj.delete()`. The priority it covered then escalates to nobody, which is
* what the confirm line has to say.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export async function deleteEscalationPolicy({ cookies }, id) {
if (!id) throw new Error('Which policy? No policy id was given.');
return await apiRequest(`/cases/escalation-policies/${id}/`, { method: 'DELETE' }, { cookies });
}

View file

@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const {
createEscalationPolicy,
updateEscalationPolicy,
deleteEscalationPolicy,
UPDATE_FIELDS
} = await import('./escalation.js');
const cookies = /** @type {any} */ ({ get: () => 'token' });
const event = /** @type {any} */ ({ cookies });
const base = {
priority: 'Urgent',
first_response_action: 'notify',
resolution_action: 'reassign',
first_response_target_id: 'p1',
resolution_target_id: 'p2',
notify_team_id: 't1',
is_active: true
};
describe('UPDATE_FIELDS', () => {
it('excludes priority, the frozen natural key', () => {
expect(UPDATE_FIELDS).not.toContain('priority');
});
});
describe('createEscalationPolicy', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('includes priority on create, along with the rest of the allow-list, and drops a hostile org', async () => {
apiRequest.mockResolvedValue({});
await createEscalationPolicy(event, {
...base,
org: 'ATTACKER-ORG',
created_by: 'ATTACKER'
});
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/escalation-policies/');
expect(opts.method).toBe('POST');
expect(opts.body.priority).toBe('Urgent');
expect(Object.keys(opts.body).sort()).toEqual([
'first_response_action',
'first_response_target_id',
'is_active',
'notify_team_id',
'priority',
'resolution_action',
'resolution_target_id'
]);
expect(opts.body.org).toBeUndefined();
expect(opts.body.created_by).toBeUndefined();
});
it('sends null, not an empty string, for an unselected first_response_target_id', async () => {
apiRequest.mockResolvedValue({});
await createEscalationPolicy(event, { ...base, first_response_target_id: '' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.first_response_target_id).toBeNull();
});
it('sends null, not an empty string, for an unselected resolution_target_id', async () => {
apiRequest.mockResolvedValue({});
await createEscalationPolicy(event, { ...base, resolution_target_id: '' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.resolution_target_id).toBeNull();
});
it('sends null, not an empty string, for an unselected notify_team_id', async () => {
apiRequest.mockResolvedValue({});
await createEscalationPolicy(event, { ...base, notify_team_id: '' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.notify_team_id).toBeNull();
});
it('coerces is_active to a boolean', async () => {
apiRequest.mockResolvedValue({});
await createEscalationPolicy(event, { ...base, is_active: 'true' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.is_active).toBe(true);
expect(typeof body.is_active).toBe('boolean');
});
it('rejects a priority outside the four choices before the round trip', async () => {
await expect(createEscalationPolicy(event, { ...base, priority: 'Critical' })).rejects.toThrow(
/priority/i
);
expect(apiRequest).not.toHaveBeenCalled();
});
it('rejects a missing priority before the round trip', async () => {
await expect(createEscalationPolicy(event, { ...base, priority: '' })).rejects.toThrow(
/priority/i
);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('updateEscalationPolicy', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('PUTs to the detail endpoint', async () => {
apiRequest.mockResolvedValue({});
await updateEscalationPolicy(event, 'e1', base);
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/escalation-policies/e1/');
expect(opts.method).toBe('PUT');
});
it('never sends priority, even when the caller passes one', async () => {
apiRequest.mockResolvedValue({});
await updateEscalationPolicy(event, 'e1', base);
const { body } = apiRequest.mock.calls[0][1];
expect(body.priority).toBeUndefined();
expect(Object.keys(body)).not.toContain('priority');
});
it('sends only what it is given, with no org/created_by', async () => {
apiRequest.mockResolvedValue({});
await updateEscalationPolicy(event, 'e1', {
...base,
org: 'ATTACKER-ORG',
created_by: 'ATTACKER'
});
const { body } = apiRequest.mock.calls[0][1];
expect(Object.keys(body).sort()).toEqual([
'first_response_action',
'first_response_target_id',
'is_active',
'notify_team_id',
'resolution_action',
'resolution_target_id'
]);
expect(body.org).toBeUndefined();
expect(body.created_by).toBeUndefined();
});
it('sends exactly one key for a minimal { is_active: true } body (the turn-on path)', async () => {
apiRequest.mockResolvedValue({});
await updateEscalationPolicy(event, 'e1', { is_active: true });
const { body } = apiRequest.mock.calls[0][1];
expect(body).toEqual({ is_active: true });
});
it('sends exactly one key for a minimal { is_active: false } body (the turn-off path)', async () => {
apiRequest.mockResolvedValue({});
await updateEscalationPolicy(event, 'e1', { is_active: false });
const { body } = apiRequest.mock.calls[0][1];
expect(body).toEqual({ is_active: false });
});
it('sends null, not an empty string, for an unselected target on update', async () => {
apiRequest.mockResolvedValue({});
await updateEscalationPolicy(event, 'e1', { ...base, first_response_target_id: '' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.first_response_target_id).toBeNull();
});
it('throws when the id is missing', async () => {
await expect(updateEscalationPolicy(event, '', base)).rejects.toThrow(/which policy/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('deleteEscalationPolicy', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('DELETEs the detail endpoint', async () => {
apiRequest.mockResolvedValue({});
await deleteEscalationPolicy(event, 'e1');
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/escalation-policies/e1/');
expect(opts.method).toBe('DELETE');
});
it('throws when the id is missing', async () => {
await expect(deleteEscalationPolicy(event, '')).rejects.toThrow(/which policy/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});

View file

@ -34,6 +34,7 @@
* fields already in the payload, never a second aggregate query.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { getOrgPeopleAndTeams } from './org-people.js';
/** The two goal kinds the backend accepts (common/utils.py GOAL_TYPES). */
export const GOAL_TYPES = ['REVENUE', 'DEALS_CLOSED'];
@ -191,31 +192,6 @@ export async function listGoals({ cookies }) {
};
}
/**
* In-org people and teams for the create/edit pickers.
* `/users/get-teams-and-users/` filters both to `request.profile.org`, so the
* picker can only ever offer in-org targets. The same boundary the serializer
* now enforces, surfaced early as UX.
*
* @param {import('@sveltejs/kit').Cookies} cookies
*/
async function targetOptions(cookies) {
try {
const resp = await apiRequest('/users/get-teams-and-users/', {}, { cookies });
const people = (resp?.profiles ?? []).map((/** @type {any} */ p) => ({
id: p.id,
name: personName(p)
}));
const teams = (resp?.teams ?? []).map((/** @type {any} */ t) => ({
id: t.id,
name: t.name
}));
return { people, teams };
} catch {
return { people: [], teams: [] };
}
}
/**
* What the create form needs. `can_edit` gates the whole route: creating a goal
* is admin-only server-side, and a non-admin who reaches `/goals/new` gets
@ -225,7 +201,7 @@ async function targetOptions(cookies) {
*/
export async function getGoalFormOptions({ cookies }) {
if (viewerRole(cookies) !== 'ADMIN') return { can_edit: false };
const { people, teams } = await targetOptions(cookies);
const { people, teams } = await getOrgPeopleAndTeams(cookies);
return { can_edit: true, people, teams };
}
@ -242,7 +218,7 @@ export async function getGoalForEdit({ cookies }, id) {
const [raw, { people, teams }] = await Promise.all([
apiRequest(`/opportunities/goals/${id}/`, {}, { cookies }),
targetOptions(cookies)
getOrgPeopleAndTeams(cookies)
]);
const target = raw.assigned_to

View file

@ -12,14 +12,22 @@
* came from the provider. Anything holding it can forge tickets into this org.
* The backend already strips it for non-admins, but this layer drops it for
* everyone: it is never needed to render the page, so it never reaches the
* browser, admin or not. Rotation is an explicit action elsewhere, not a field
* on a page you can browse to.
* browser, admin or not. `topic_arn` embeds the AWS account id and is held to
* the same bar. Rotation and a one-time reveal on create are an explicit,
* separate feature this task does not build, not a field on a page you can
* browse to.
*
* Read-only page: "Add address" / "Edit" are deferred builders, so there is no
* write path here. The backend returns `default_assignee` as a full profile
* object (name under `user_details.name`); the page wants `{ id, name }`.
* The rule extends to the write side, below: `CREATE_FIELDS` and
* `UPDATE_FIELDS` exclude both fields too, on purpose, not by oversight. See
* the comment above `CREATE_FIELDS` for why omitting them is strictly better
* than any form could do.
*
* Create, edit, turn off, turn on and delete are wired below. The backend
* returns `default_assignee` as a full profile object (name under
* `user_details.name`); the page wants `{ id, name }`.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { viewerRole } from './organization.js';
/** Profile → the `{ id, name }` the card reads, or null. */
function shapeAssignee(p) {
@ -29,7 +37,7 @@ function shapeAssignee(p) {
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ mailboxes: any[], totals: any }>}
* @returns {Promise<{ mailboxes: any[], totals: any, can_edit: boolean }>}
*/
export async function getMailboxes({ cookies }) {
const { mailboxes, totals } = await apiRequest('/cases/mailboxes/', {}, { cookies });
@ -48,6 +56,82 @@ export async function getMailboxes({ cookies }) {
cases_last_30d: m.cases_last_30d ?? 0,
last_received_at: m.last_received_at ?? null
})),
totals: totals ?? { count: 0, active: 0, cases_last_30d: 0 }
totals: totals ?? { count: 0, active: 0, cases_last_30d: 0 },
// A display hint: POST/PUT/DELETE on `/cases/mailboxes/` each start with
// `_is_admin(request.profile)` server-side and 403 regardless of what this
// says. This only decides whether the page offers the controls. GET itself
// is not role-gated, so a member can still see this list.
can_edit: viewerRole(cookies) === 'ADMIN'
};
}
/** No `webhook_secret`, no `topic_arn`. `InboundMailboxListCreateView.post`
* generates a `secrets.token_urlsafe(32)` secret when the body carries none,
* which is a better secret than a pasted one, and `put` is `partial=True`,
* so omitting the key leaves the live credential untouched. A form field for
* either would mean the credential travels to a browser and back on every
* edit, and an empty one would blank it. */
const CREATE_FIELDS = [
'address',
'provider',
'default_priority',
'default_case_type',
'default_assignee_id',
'is_active'
];
/** `address` is the mailbox's identity and its uniqueness key. It is not
* frozen by the backend (`validate_address` only checks duplicates on
* create, and `put` is partial), so an edit may change it. It stays in the
* update allow-list for that reason, with a hint on the form saying mail to
* the old address stops becoming tickets. */
const UPDATE_FIELDS = CREATE_FIELDS;
/** @param {string[]} allowed @param {{ [key: string]: any }} values */
function buildBody(allowed, values) {
/** @type {Record<string, any>} */
const body = {};
for (const field of allowed) {
if (values[field] === undefined) continue;
body[field] = values[field];
}
// Two nullable fields whose "none selected" option is an empty `<option>`
// value. `''` fails the ChoiceField and the PK lookup alike; `null` is what
// clears them, and both are `allow_null=True` on the serializer.
for (const nullable of ['default_case_type', 'default_assignee_id']) {
if (body[nullable] === '') body[nullable] = null;
}
if (body.is_active !== undefined) body.is_active = Boolean(body.is_active);
if (body.address !== undefined) {
body.address = String(body.address).trim().toLowerCase();
}
return body;
}
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function createMailbox({ cookies }, values) {
const body = buildBody(CREATE_FIELDS, values);
if (!body.address) throw new Error('A mailbox needs an address.');
return await apiRequest('/cases/mailboxes/', { method: 'POST', body }, { cookies });
}
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function updateMailbox({ cookies }, id, values) {
if (!id) throw new Error('Which mailbox? No mailbox id was given.');
const body = buildBody(UPDATE_FIELDS, values);
return await apiRequest(`/cases/mailboxes/${id}/`, { method: 'PUT', body }, { cookies });
}
/**
* Delete a mailbox, permanently.
*
* `InboundMailboxDetailView.delete` calls `obj.delete()`. Hard delete, and it
* takes the row's `webhook_secret` with it, so mail already in flight from the
* provider stops verifying the moment this runs, not just stops opening tickets.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export async function deleteMailbox({ cookies }, id) {
if (!id) throw new Error('Which mailbox? No mailbox id was given.');
return await apiRequest(`/cases/mailboxes/${id}/`, { method: 'DELETE' }, { cookies });
}

View file

@ -0,0 +1,197 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { createMailbox, updateMailbox, deleteMailbox } = await import('./inbound-email.js');
const cookies = /** @type {any} */ ({ get: () => 'token' });
const event = /** @type {any} */ ({ cookies });
const base = {
address: 'Support@Example.io',
provider: 'ses',
default_priority: 'Normal',
default_case_type: 'Question',
default_assignee_id: 'p1',
is_active: true
};
describe('createMailbox', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('sends only allow-listed keys on create and drops a hostile org', async () => {
apiRequest.mockResolvedValue({});
await createMailbox(event, {
...base,
org: 'ATTACKER-ORG',
created_by: 'ATTACKER'
});
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/mailboxes/');
expect(opts.method).toBe('POST');
expect(Object.keys(opts.body).sort()).toEqual([
'address',
'default_assignee_id',
'default_case_type',
'default_priority',
'is_active',
'provider'
]);
expect(opts.body.org).toBeUndefined();
expect(opts.body.created_by).toBeUndefined();
});
it('never sends a webhook_secret or topic_arn, even when handed one', async () => {
apiRequest.mockResolvedValue({});
await createMailbox(event, {
...base,
webhook_secret: 'ATTACKER-CHOSEN',
topic_arn: 'arn:aws:sns:us-east-1:999999999999:x'
});
const [, opts] = apiRequest.mock.calls[0];
expect(opts.body.webhook_secret).toBeUndefined();
expect(opts.body.topic_arn).toBeUndefined();
});
it('trims and lowercases the address before sending', async () => {
apiRequest.mockResolvedValue({});
await createMailbox(event, { ...base, address: ' Support@Example.IO ' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.address).toBe('support@example.io');
});
it('sends null, not an empty string, for an unselected default_case_type', async () => {
apiRequest.mockResolvedValue({});
await createMailbox(event, { ...base, default_case_type: '' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.default_case_type).toBeNull();
});
it('sends null, not an empty string, for an unselected default_assignee_id', async () => {
apiRequest.mockResolvedValue({});
await createMailbox(event, { ...base, default_assignee_id: '' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.default_assignee_id).toBeNull();
});
it('coerces is_active to a boolean', async () => {
apiRequest.mockResolvedValue({});
await createMailbox(event, { ...base, is_active: 'true' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.is_active).toBe(true);
expect(typeof body.is_active).toBe('boolean');
});
it('refuses a mailbox with no address before making a request', async () => {
await expect(createMailbox(event, { ...base, address: '' })).rejects.toThrow(
/needs an address/i
);
expect(apiRequest).not.toHaveBeenCalled();
});
it('refuses a mailbox whose address is only whitespace before making a request', async () => {
await expect(createMailbox(event, { ...base, address: ' ' })).rejects.toThrow(
/needs an address/i
);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('updateMailbox', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('PUTs to the detail endpoint', async () => {
apiRequest.mockResolvedValue({});
await updateMailbox(event, 'm1', base);
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/mailboxes/m1/');
expect(opts.method).toBe('PUT');
});
it('sends only what it is given, with no org/created_by', async () => {
apiRequest.mockResolvedValue({});
await updateMailbox(event, 'm1', {
...base,
org: 'ATTACKER-ORG',
created_by: 'ATTACKER'
});
const { body } = apiRequest.mock.calls[0][1];
expect(Object.keys(body).sort()).toEqual([
'address',
'default_assignee_id',
'default_case_type',
'default_priority',
'is_active',
'provider'
]);
expect(body.org).toBeUndefined();
expect(body.created_by).toBeUndefined();
});
it('never sends a webhook_secret or topic_arn, even when handed one', async () => {
apiRequest.mockResolvedValue({});
await updateMailbox(event, 'm1', {
...base,
webhook_secret: 'ATTACKER-CHOSEN',
topic_arn: 'arn:aws:sns:us-east-1:999999999999:x'
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.webhook_secret).toBeUndefined();
expect(body.topic_arn).toBeUndefined();
});
it('sends exactly one key for a minimal { is_active: true } body (the turn-on path)', async () => {
apiRequest.mockResolvedValue({});
await updateMailbox(event, 'm1', { is_active: true });
const { body } = apiRequest.mock.calls[0][1];
expect(body).toEqual({ is_active: true });
});
it('sends exactly one key for a minimal { is_active: false } body (the turn-off path)', async () => {
apiRequest.mockResolvedValue({});
await updateMailbox(event, 'm1', { is_active: false });
const { body } = apiRequest.mock.calls[0][1];
expect(body).toEqual({ is_active: false });
});
it('does not require an address, since a turn-on/off submits none', async () => {
apiRequest.mockResolvedValue({});
await expect(updateMailbox(event, 'm1', { is_active: true })).resolves.toBeDefined();
});
it('trims and lowercases an address when one is submitted', async () => {
apiRequest.mockResolvedValue({});
await updateMailbox(event, 'm1', { address: ' New@Example.IO ' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.address).toBe('new@example.io');
});
it('throws when the id is missing', async () => {
await expect(updateMailbox(event, '', base)).rejects.toThrow(/which mailbox/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('deleteMailbox', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('DELETEs the detail endpoint', async () => {
apiRequest.mockResolvedValue({});
await deleteMailbox(event, 'm1');
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/mailboxes/m1/');
expect(opts.method).toBe('DELETE');
});
it('throws when the id is missing', async () => {
await expect(deleteMailbox(event, '')).rejects.toThrow(/which mailbox/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});

View file

@ -456,3 +456,82 @@ async function fetchDetail(cookies, id) {
throw err;
}
}
/**
* Create a lead.
*
* Mirrors `updateLead`, with one deliberate difference: update sends only the
* fields the form submitted, because the API treats absent as "leave alone".
* Create has nothing to leave alone, so every editable field is sent and an
* empty one is sent as null, which is how the serializer records "no value".
*
* `org` and `created_by` are never sent. The backend derives both from the
* JWT, and accepting them from a form body is how a tenant boundary gets
* crossed. The loop below only ever reads keys named in EDITABLE_FIELDS, so an
* extra key in `values` cannot reach the request.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {Record<string, any>} values
* @returns {Promise<any>} the `LeadListView.post` response body. Confirmed against the
* running backend: on success this is `{ error: false, message: "Lead Created Successfully" }`,
* with no `id` field. Unlike `createDeal`'s response, which does carry one
* (`OpportunityListView.post` adds it deliberately), a caller here cannot read `.id` off the
* result to open the record it just created. `+page.server.js` accounts for this and falls
* back to the list rather than assuming an id that is not there.
*/
export async function createLead({ cookies }, values) {
/** @type {Record<string, any>} */
const body = {};
for (const field of EDITABLE_FIELDS) {
const value = values[field];
body[field] = value === '' || value === undefined ? null : value;
}
if (values.opportunity_amount !== undefined) {
const amount = values.opportunity_amount;
body.opportunity_amount = amount === '' || amount == null ? null : Number(amount);
}
// Single-select owner, sent as the list the API expects. Empty means
// unassigned, which is a real state, so it is sent rather than skipped.
body.assigned_to = values.assigned_to ? [values.assigned_to] : [];
return await apiRequest('/leads/', { method: 'POST', body }, { cookies });
}
/**
* Who the session actually is, rather than who a form claims to be. The owner
* select defaults to this. Empty on failure: an unassigned lead is a better
* outcome than a wrongly assigned one, and the select is right there.
*
* Exported for `macros.js`, which needs the same "who am I" resolution to
* compare against a macro's `owner.id`; a second copy of this call would be
* the same mistake `viewerRole` made before phase 1 consolidated it.
*
* @param {import('@sveltejs/kit').Cookies} cookies
*/
// Despite the name `user_obj`, `ProfileView.get` serializes `request.profile`
// (a Profile, not a User) through `ProfileSerializer`, so `user_obj.id` below
// is the Profile's own id. The payload key is a holdover from the API's
// naming, not a description of what it returns.
export async function myProfileId(cookies) {
try {
const response = await apiRequest('/profile/', {}, { cookies });
return response.user_obj?.id ?? '';
} catch {
return '';
}
}
/**
* Everything the create form needs to render its selects, in one call.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export async function getLeadFormOptions({ cookies }) {
const [owners, mine] = await Promise.all([listOwners(cookies), myProfileId(cookies)]);
return {
owners,
defaults: { assigned_to: mine, status: 'assigned' }
};
}

View file

@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { createLead } = await import('$lib/server/v2/leads.js');
// Cast rather than shaping a full Cookies mock: createLead only ever calls
// `cookies.get`, and `apiRequest` itself is mocked above, so nothing here
// touches `getAll`/`set`/`delete`/`serialize`. Without the cast svelte-check
// flags this object against SvelteKit's full `Cookies` type on every call site
// below, which is noise for a shape the test deliberately keeps minimal.
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
describe('createLead', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('POSTs to /leads/ and returns the created lead', async () => {
apiRequest.mockResolvedValue({ id: 'abc', first_name: 'Ada' });
const result = await createLead(event, { first_name: 'Ada', last_name: 'Lovelace' });
expect(apiRequest).toHaveBeenCalledOnce();
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/leads/');
expect(options.method).toBe('POST');
expect(options.body.first_name).toBe('Ada');
expect(result.id).toBe('abc');
});
it('sends an empty field as null rather than an empty string', async () => {
apiRequest.mockResolvedValue({ id: 'abc' });
await createLead(event, { first_name: 'Ada', job_title: '' });
expect(apiRequest.mock.calls[0][1].body.job_title).toBeNull();
});
it('coerces opportunity_amount to a number', async () => {
apiRequest.mockResolvedValue({ id: 'abc' });
await createLead(event, { first_name: 'Ada', opportunity_amount: '4200.50' });
expect(apiRequest.mock.calls[0][1].body.opportunity_amount).toBe(4200.5);
});
it('wraps a single owner id in a list, matching the API contract', async () => {
apiRequest.mockResolvedValue({ id: 'abc' });
await createLead(event, { first_name: 'Ada', assigned_to: 'profile-1' });
expect(apiRequest.mock.calls[0][1].body.assigned_to).toEqual(['profile-1']);
});
it('never forwards a client-supplied org, which the backend derives from the JWT', async () => {
apiRequest.mockResolvedValue({ id: 'abc' });
await createLead(event, { first_name: 'Ada', org: 'attacker-org', created_by: 'someone' });
const body = apiRequest.mock.calls[0][1].body;
expect(body.org).toBeUndefined();
expect(body.created_by).toBeUndefined();
});
});

View file

@ -7,9 +7,24 @@
* the reference card. Visibility is decided by the API from the JWT, a member
* never sees another member's personal macros, so nothing here filters rows.
*
* This page is read-only: it lists and explains macros; the only mutating
* control ("New macro") is a builder that is not wired yet. There is no write
* path here.
* WRITE PATHS, AND WHO MAY USE THEM
* This page's permission model is unlike the rest of settings. Every
* signed-in member may create, edit and delete their own `personal` macros;
* only an admin may do any of that to an `org`-scope macro.
* `_resolve_scope_and_owner` enforces the scope rule server-side and turns a
* non-admin's org-scope attempt into 403, so `owner` is never sent from
* here: it is derived from `request.profile` on the way in, and a client
* that could name one could file a macro as somebody else. Editing someone
* else's personal macro answers 404, not 403, on purpose
* (`MacroDetailView._get_writable`), so the id space cannot be used to
* discover which rows are other people's private macros; callers of
* `updateMacro`/`deleteMacro` have to carry that distinction through to
* their error copy rather than "fixing" it into a uniform 403.
*
* Delete is soft for an org macro (`is_active` flips to false, the row stays
* and stays counted) and a hard delete for a personal one. Both go through
* `deleteMacro`, one endpoint; which happens is decided by the row's own
* scope server-side, never by anything this module sends.
*
* The one reshape is `owner`: the API returns it as a Profile id plus a
* separate `owner_name` (the owner's email; `User` has no display name), while
@ -20,10 +35,12 @@
* the renderer actually expands.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { viewerRole } from './organization.js';
import { myProfileId } from './leads.js';
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ macros: any[], totals: any, placeholders: any[] }>}
* @returns {Promise<{ macros: any[], totals: any, placeholders: any[], can_create_org: boolean, my_profile_id: string }>}
*/
export async function getMacros({ cookies }) {
const resp = await apiRequest('/macros/', {}, { cookies });
@ -40,6 +57,108 @@ export async function getMacros({ cookies }) {
inactive: 0,
with_unknown_placeholders: 0
},
placeholders: resp.placeholders ?? []
placeholders: resp.placeholders ?? [],
// A display hint, not the authorization: `_resolve_scope_and_owner`
// re-derives admin status from `request.profile` server-side, and that
// is what actually decides whether an org-scope write succeeds. This
// only decides whether the scope select offers "Everyone in the org".
can_create_org: viewerRole(cookies) === 'ADMIN',
// So the page can tell its own personal macros apart from a stranger's.
// The API already keeps a stranger's personal macros out of `results`
// entirely, so this is only ever compared against rows the viewer could
// already see.
my_profile_id: await myProfileId(cookies)
};
}
/** The fields `MacroSerializer` accepts on create or update. `owner` is
* derived server-side by `_resolve_scope_and_owner` from `request.profile`,
* never from the body, so it can never appear here. `org` is a JWT claim
* for the same reason. */
const WRITABLE_FIELDS = ['title', 'body', 'scope', 'is_active'];
/**
* Shape the request body from the allow-list.
*
* The three checks below are a fast fail for an obviously bad form, not the
* authority: `MacroSerializer` requires both `title` and `body`, and
* `_resolve_scope_and_owner` rejects any scope outside these two, so the
* server re-validates everything here regardless of what this lets through.
*
* @param {{ [key: string]: any }} values
*/
function buildBody(values) {
/** @type {Record<string, any>} */
const body = {};
for (const field of WRITABLE_FIELDS) {
if (values[field] === undefined) continue;
body[field] = values[field];
}
body.title = String(body.title ?? '').trim();
body.body = String(body.body ?? '').trim();
if (!body.title) throw new Error('A macro needs a title.');
if (!body.body) throw new Error('A macro needs a body.');
if (body.scope !== 'org' && body.scope !== 'personal') {
throw new Error("scope must be 'org' or 'personal'.");
}
if (body.is_active !== undefined) body.is_active = Boolean(body.is_active);
return body;
}
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function createMacro({ cookies }, values) {
const body = buildBody(values);
return await apiRequest('/macros/', { method: 'POST', body }, { cookies });
}
/**
* PATCH, not PUT: `MacroDetailView.put` runs the serializer with
* `partial=False` and `MacroSerializer` requires both `title` and `body`, so
* a PUT missing either 400s. `patch` is the partial verb and is what this
* form, which always submits both fields anyway, should be using regardless.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export async function updateMacro({ cookies }, id, values) {
if (!id) throw new Error('Which macro? No macro id was given.');
const body = buildBody(values);
return await apiRequest(`/macros/${id}/`, { method: 'PATCH', body }, { cookies });
}
/**
* Delete is soft for an org macro (`MacroDetailView.delete` flips
* `is_active` and leaves the row in place) and a hard delete for a personal
* one. Both happen behind this one call; which one is decided by the row's
* own scope server-side, not by anything sent here.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export async function deleteMacro({ cookies }, id) {
if (!id) throw new Error('Which macro? No macro id was given.');
return await apiRequest(`/macros/${id}/`, { method: 'DELETE' }, { cookies });
}
/**
* Turn a macro back on.
*
* A dedicated action, not a reuse of `updateMacro`: `buildBody` above
* unconditionally requires both `title` and `body` to be non-empty (a fast
* client-side fail for the create/edit form, which always submits both), so
* calling `updateMacro(event, id, { is_active: true })` would throw before a
* request was even made. This bypasses `buildBody` and sends exactly
* `{ is_active: true }`. PATCH, partial, so the rest of the row is untouched.
* `_get_writable` still enforces the same admin-only-for-org /
* owner-only-for-personal rule as any other write to this row.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export async function activateMacro({ cookies }, id) {
if (!id) throw new Error('Which macro? No macro id was given.');
return await apiRequest(
`/macros/${id}/`,
{ method: 'PATCH', body: { is_active: true } },
{ cookies }
);
}

View file

@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { getMacros, createMacro, updateMacro, deleteMacro, activateMacro } =
await import('$lib/server/v2/macros.js');
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
/**
* `getMacros`'s `can_create_org` comes from `viewerRole`, which decodes the
* `role` claim out of the `jwt_access` cookie directly (no network call), so
* a plain `{ get: () => 'token' }` mock (as `event` above) can't drive it: a
* `'token'` has no `.` to split, `viewerRole` catches that and returns null.
* This builds a JWT-shaped string with a real base64url payload so the
* decode path actually runs, matching how `organization.js`'s `viewerRole`
* reads a real access token.
*
* @param {string} role
*/
function eventWithRole(role) {
const payload = Buffer.from(JSON.stringify({ role }), 'utf-8').toString('base64url');
const token = `h.${payload}.s`;
return /** @type {any} */ ({
cookies: { get: (/** @type {string} */ name) => (name === 'jwt_access' ? token : null) }
});
}
describe('getMacros', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('can_create_org is true for an ADMIN token and false for a non-admin one', async () => {
// The page's whole permission model rests on this one boolean: it is
// what decides whether the scope select offers "Everyone in the org" and
// whether `canWrite` treats an org macro as editable. `_resolve_scope_and_owner`
// re-derives admin status independently server-side, but a display hint
// that is wired backwards is still worth catching here.
apiRequest.mockImplementation(async (/** @type {string} */ url) => {
if (url === '/macros/') return { results: [], totals: {}, placeholders: [] };
if (url === '/profile/') return { user_obj: { id: 'profile-1' } };
throw new Error(`unexpected url: ${url}`);
});
const admin = await getMacros(eventWithRole('ADMIN'));
expect(admin.can_create_org).toBe(true);
const member = await getMacros(eventWithRole('USER'));
expect(member.can_create_org).toBe(false);
});
it('my_profile_id is the id the page compares a macro owner against', async () => {
// `canWrite` in the page does `m.owner?.id === data.my_profile_id` for a
// personal macro. `getMacros` reshapes `owner` from the raw profile id
// the API sends into `{ id, name }`; this proves that reshape keeps the
// id in the same identity space `myProfileId` resolves, so a viewer's
// own personal macro actually compares equal rather than silently never
// matching.
apiRequest.mockImplementation(async (/** @type {string} */ url) => {
if (url === '/macros/') {
return {
results: [
{
id: 'm1',
scope: 'personal',
owner: 'profile-77',
owner_name: 'a@example.com',
is_active: true,
usage_count: 0,
unknown_placeholders: []
},
{
id: 'm2',
scope: 'org',
owner: null,
is_active: true,
usage_count: 0,
unknown_placeholders: []
}
],
totals: {},
placeholders: []
};
}
if (url === '/profile/') return { user_obj: { id: 'profile-77' } };
throw new Error(`unexpected url: ${url}`);
});
const data = await getMacros(eventWithRole('USER'));
expect(data.my_profile_id).toBe('profile-77');
const mine = data.macros.find((/** @type {any} */ m) => m.id === 'm1');
expect(mine.owner.id).toBe(data.my_profile_id);
});
it('my_profile_id falls back to an empty string, never a value that could collide with an owner id', async () => {
apiRequest.mockImplementation(async (/** @type {string} */ url) => {
if (url === '/macros/') return { results: [], totals: {}, placeholders: [] };
if (url === '/profile/') throw new Error('502');
throw new Error(`unexpected url: ${url}`);
});
const data = await getMacros(eventWithRole('USER'));
expect(data.my_profile_id).toBe('');
});
});
describe('createMacro', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('POSTs title, body, scope and is_active to /macros/', async () => {
apiRequest.mockResolvedValue({ id: 'm1' });
await createMacro(event, {
title: ' Ask for logs ',
body: 'Could you attach the log file?',
scope: 'personal',
is_active: true
});
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/macros/');
expect(options.method).toBe('POST');
expect(options.body).toEqual({
title: 'Ask for logs',
body: 'Could you attach the log file?',
scope: 'personal',
is_active: true
});
});
it('never forwards owner or org, which the backend derives from the token', async () => {
// `_resolve_scope_and_owner` sets owner from `request.profile`. A client
// that could name an owner could file a macro as somebody else.
apiRequest.mockResolvedValue({});
await createMacro(event, {
title: 't',
body: 'b',
scope: 'personal',
owner: 'someone-else',
org: 'attacker-org'
});
const body = apiRequest.mock.calls[0][1].body;
expect(body.owner).toBeUndefined();
expect(body.org).toBeUndefined();
});
it('rejects an unknown scope before making a request', async () => {
await expect(createMacro(event, { title: 't', body: 'b', scope: 'global' })).rejects.toThrow(
/scope/i
);
expect(apiRequest).not.toHaveBeenCalled();
});
it('rejects an empty title or body before making a request', async () => {
await expect(createMacro(event, { title: ' ', body: 'b', scope: 'org' })).rejects.toThrow(
/title/i
);
await expect(createMacro(event, { title: 't', body: ' ', scope: 'org' })).rejects.toThrow(
/body/i
);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('updateMacro', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('PATCHes rather than PUTs, because PUT is not partial', async () => {
// `MacroDetailView.put` runs the serializer with `partial=False` and
// `MacroSerializer` requires title and body, so a PUT missing either
// returns 400. PATCH is the partial verb.
apiRequest.mockResolvedValue({});
await updateMacro(event, 'm1', { title: 'New title', body: 'b', scope: 'personal' });
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/macros/m1/');
expect(options.method).toBe('PATCH');
expect(options.body.title).toBe('New title');
});
});
describe('deleteMacro', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('DELETEs the detail endpoint', async () => {
apiRequest.mockResolvedValue(null);
await deleteMacro(event, 'm1');
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/macros/m1/');
expect(options.method).toBe('DELETE');
});
it('refuses an empty id before making a request', async () => {
await expect(deleteMacro(event, '')).rejects.toThrow(/which/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('activateMacro', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('PATCHes the detail endpoint with only { is_active: true }', async () => {
// The "Turn on" control must never resend `title`/`body`/`scope`: a
// blank `title` slipping through here would blank a live macro's title.
apiRequest.mockResolvedValue({});
await activateMacro(event, 'm1');
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/macros/m1/');
expect(options.method).toBe('PATCH');
expect(options.body).toEqual({ is_active: true });
});
it('refuses an empty id before making a request', async () => {
await expect(activateMacro(event, '')).rejects.toThrow(/which/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,54 @@
/**
* The in-org people and teams every picker needs.
*
* Server-only. `GET /users/get-teams-and-users/` filters profiles to
* `is_active=True, org=request.profile.org` and teams to the same org, so a
* picker built from this can only ever offer an active, in-org target.
*
* That is UX, not the boundary. Every consumer's own write path re-checks the
* id against the caller's org, and they do it in three different ways, so do
* not read one mechanism into all of them: the four `cases` settings
* serializers scope their relational querysets to the caller's org in
* `__init__` and reject a foreign id with a 400; `SalesGoalCreateSerializer`
* raises a 400 from `validate_assigned_to` / `validate_team`; the document
* views filter `Profile.objects.filter(id__in=..., org=..., is_active=True)`
* inline and silently drop a foreign id rather than failing. All three close
* the hole. None of them depend on what this list offered.
*
* This exists because four modules had grown their own private copy of the
* same fetch. `documents.js` and `goals.js` were byte-identical in behaviour
* and now import this. `deals.js` and `leads.js` keep theirs: they label a
* person by email rather than name, and changing what their owner select
* displays is not a refactor.
*
* A failed fetch resolves to empty lists rather than throwing. The pickers
* are one part of a settings page, and taking the whole page down to a 500
* because an optional list did not load is worse than rendering it with
* nothing to choose.
*/
import { apiRequest } from '$lib/api-helpers.js';
/** A profile as the pickers label it. */
function personName(profile) {
const d = profile?.user_details ?? {};
return d.name || d.email || 'Unnamed';
}
/**
* @param {import('@sveltejs/kit').Cookies} cookies
* @returns {Promise<{ people: { id: string, name: string }[], teams: { id: string, name: string }[] }>}
*/
export async function getOrgPeopleAndTeams(cookies) {
try {
const resp = await apiRequest('/users/get-teams-and-users/', {}, { cookies });
return {
people: (resp?.profiles ?? []).map((/** @type {any} */ p) => ({
id: p.id,
name: personName(p)
})),
teams: (resp?.teams ?? []).map((/** @type {any} */ t) => ({ id: t.id, name: t.name }))
};
} catch {
return { people: [], teams: [] };
}
}

View file

@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Local mock plus a thin wrapper, matching every other test in this
// directory (business-hours.test.js, tags.test.js, etc). Destructuring
// `apiRequest` straight off `await import('$lib/api-helpers.js')` types it
// against the real module's declared signature, which has no
// `mockReset`/`mockResolvedValue`, and fails `svelte-check`.
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { getOrgPeopleAndTeams } = await import('./org-people.js');
const cookies = /** @type {any} */ ({});
describe('getOrgPeopleAndTeams', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('labels a person by name when there is one', async () => {
apiRequest.mockResolvedValue({
profiles: [{ id: 'p1', user_details: { name: 'Ada Lovelace', email: 'ada@x.io' } }],
teams: []
});
const { people } = await getOrgPeopleAndTeams(cookies);
expect(people).toEqual([{ id: 'p1', name: 'Ada Lovelace' }]);
});
it('falls back to the email, then to Unnamed', async () => {
apiRequest.mockResolvedValue({
profiles: [
{ id: 'p1', user_details: { email: 'ada@x.io' } },
{ id: 'p2', user_details: {} },
{ id: 'p3' }
],
teams: []
});
const { people } = await getOrgPeopleAndTeams(cookies);
expect(people.map((p) => p.name)).toEqual(['ada@x.io', 'Unnamed', 'Unnamed']);
});
it('flattens teams to id and name', async () => {
apiRequest.mockResolvedValue({
profiles: [],
teams: [{ id: 't1', name: 'Support', description: 'ignored', users: [1, 2] }]
});
const { teams } = await getOrgPeopleAndTeams(cookies);
expect(teams).toEqual([{ id: 't1', name: 'Support' }]);
});
it('returns empty lists rather than throwing when the fetch fails', async () => {
apiRequest.mockImplementation(() => {
throw new Error('boom');
});
await expect(getOrgPeopleAndTeams(cookies)).resolves.toEqual({ people: [], teams: [] });
});
it('survives a response missing both keys', async () => {
apiRequest.mockResolvedValue({});
await expect(getOrgPeopleAndTeams(cookies)).resolves.toEqual({ people: [], teams: [] });
});
});

View file

@ -9,17 +9,69 @@
* computed server-side from the reopen audit trail (REOPENED activities + the
* out-of-window flag the signal writes on late replies).
*
* Read-only page: "Edit policy" is a deferred builder (no form on the page yet),
* so there is no write path to wire here. The whole flat response is the page's
* `policy` object.
* `updateReopenPolicy` below is the write path: `PUT /cases/reopen-policy/`,
* admin-only, `partial=True` on the backend but sent as all four fields at
* once. The page's own `+page.server.js` action turns a rejected save into a
* message the user reads.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { viewerRole } from './organization.js';
import { REOPEN_TO_STATUSES } from '$lib/v2/enums.js';
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ policy: any }>}
* @returns {Promise<{ policy: any, can_edit: boolean }>}
*/
export async function getReopenPolicy({ cookies }) {
const policy = await apiRequest('/cases/reopen-policy/', {}, { cookies });
return { policy };
return { policy, can_edit: viewerRole(cookies) === 'ADMIN' };
}
/** The four fields the serializer accepts. Everything else in the GET
* response is a computed metric and is not writable. */
const EDITABLE_FIELDS = [
'is_enabled',
'reopen_window_days',
'reopen_to_status',
'notify_assigned'
];
/**
* Update the org's reopen policy.
*
* `ReopenPolicyView.put` is admin-only and `partial=True`, so this could send
* a subset. It sends all four anyway: the form edits all four at once, and a
* partial body would make "unchecked" indistinguishable from "not submitted"
* for the two booleans.
*
* The two guards below are fast fails for an obvious mistake, not security
* controls. `validate_reopen_window_days` and `validate_reopen_to_status` on
* the serializer are the real authority and run regardless of what reaches
* them.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {{ [key: string]: any }} values callers may pass extra keys (a
* computed metric read back off the page, a hostile `org`); the allow-list
* loop below is what stops them reaching the API.
* @returns {Promise<any>} the updated policy, the four fields only.
*/
export async function updateReopenPolicy({ cookies }, values) {
const days = Number(values.reopen_window_days);
if (!Number.isInteger(days) || days < 1 || days > 365) {
throw new Error('The reopen window must be a whole number of days between 1 and 365.');
}
if (!REOPEN_TO_STATUSES.includes(values.reopen_to_status)) {
throw new Error('A reopened ticket has to come back as New, Assigned or Pending.');
}
/** @type {Record<string, any>} */
const body = {};
for (const field of EDITABLE_FIELDS) {
body[field] = values[field];
}
body.reopen_window_days = days;
body.is_enabled = Boolean(values.is_enabled);
body.notify_assigned = Boolean(values.notify_assigned);
return await apiRequest('/cases/reopen-policy/', { method: 'PUT', body }, { cookies });
}

View file

@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { updateReopenPolicy } = await import('$lib/server/v2/reopen.js');
// Cast rather than shaping a full Cookies mock, matching tags.test.js: the
// function under test only reads `cookies.get`, and `apiRequest` is mocked.
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
describe('updateReopenPolicy', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('PUTs the four policy fields to the singleton endpoint', async () => {
apiRequest.mockResolvedValue({ is_enabled: true, reopen_window_days: 14 });
await updateReopenPolicy(event, {
is_enabled: true,
reopen_window_days: 14,
reopen_to_status: 'Assigned',
notify_assigned: false
});
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/cases/reopen-policy/');
expect(options.method).toBe('PUT');
expect(options.body).toEqual({
is_enabled: true,
reopen_window_days: 14,
reopen_to_status: 'Assigned',
notify_assigned: false
});
});
it('never forwards a computed metric or a client-supplied org', async () => {
apiRequest.mockResolvedValue({});
await updateReopenPolicy(event, {
is_enabled: true,
reopen_window_days: 7,
reopen_to_status: 'New',
notify_assigned: true,
reopened_last_30d: 999,
org: 'attacker-org'
});
const body = apiRequest.mock.calls[0][1].body;
expect(body.reopened_last_30d).toBeUndefined();
expect(body.org).toBeUndefined();
});
it('rejects a window outside 1 to 365 before making a request', async () => {
await expect(
updateReopenPolicy(event, {
is_enabled: true,
reopen_window_days: 0,
reopen_to_status: 'New',
notify_assigned: true
})
).rejects.toThrow(/between 1 and 365/i);
expect(apiRequest).not.toHaveBeenCalled();
});
it('rejects a terminal reopen_to_status before making a request', async () => {
await expect(
updateReopenPolicy(event, {
is_enabled: true,
reopen_window_days: 7,
reopen_to_status: 'Closed',
notify_assigned: true
})
).rejects.toThrow(/New, Assigned or Pending/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});

View file

@ -9,13 +9,17 @@
* page can name who is next). The response also carries org totals, including
* `unrouted_last_30d` (cases no rule matched).
*
* Read-only page: "New rule" / drag-to-reorder are deferred builders, so there
* is no write path to wire. The backend returns `target_assignees` as full
* profile objects (name under `user_details.name`, plus `is_active`, which the
* page flags on) and `target_team` as a full team object; the page wants
* `{ id, name, is_active }` / `{ id, name }`, so flatten here.
* Create, edit, turn off, turn on and delete are wired below. Drag-to-reorder
* is still a deferred builder, so `priority_order` is a plain number field on
* the form rather than something a drag sets. The backend returns
* `target_assignees` as full profile objects (name under `user_details.name`,
* plus `is_active`, which the page flags on) and `target_team` as a full team
* object; the page wants `{ id, name, is_active }` / `{ id, name }`, so
* flatten here.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { viewerRole } from './organization.js';
import { CONDITION_FIELD_LABEL, CONDITION_OP_LABEL, ROUTING_STRATEGY_NAME } from '$lib/v2/enums.js';
/** Profile → the `{ id, name, is_active }` the rule card reads. */
function shapeAssignee(p) {
@ -30,7 +34,7 @@ function shapeTeam(team) {
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ rules: any[], totals: any }>}
* @returns {Promise<{ rules: any[], totals: any, can_edit: boolean }>}
*/
export async function getRoutingRules({ cookies }) {
const { rules, totals } = await apiRequest('/cases/routing-rules/', {}, { cookies });
@ -48,6 +52,250 @@ export async function getRoutingRules({ cookies }) {
state: r.state ?? null,
matched_last_30d: r.matched_last_30d ?? 0
})),
totals: totals ?? { count: 0, active: 0, unrouted_last_30d: 0 }
totals: totals ?? { count: 0, active: 0, unrouted_last_30d: 0 },
// A display hint: POST/PUT/DELETE on `/cases/routing-rules/` each start
// with `_is_admin(request.profile)` server-side and 403 regardless of
// what this says. This only decides whether the page offers the controls.
can_edit: viewerRole(cookies) === 'ADMIN'
};
}
/** Everything a create may set. `org` is the JWT's, never the body's. */
const CREATE_FIELDS = [
'name',
'priority_order',
'is_active',
'conditions',
'strategy',
'stop_processing',
'target_assignee_ids',
'target_team_id'
];
/** Nothing is frozen after creation on this resource, so an edit may set the
* same fields a create may. `RoutingRuleDetailView.put` builds its serializer
* with `partial=True`, so an omitted key is left untouched rather than
* blanked, which is what lets the turn-on action below send a single key. */
const UPDATE_FIELDS = CREATE_FIELDS;
/** The fields and operators `RoutingRuleSerializer.validate_conditions`
* accepts, derived from the label maps the page renders its selects from.
* Derived rather than restated: a `.svelte` file cannot import from
* `$lib/server/`, so the page cannot share this module's list, and two
* hand-maintained copies of the same set drift. Keying off the labels
* means adding a field in one place adds it to both. */
export const CONDITION_FIELDS = Object.keys(CONDITION_FIELD_LABEL);
export const CONDITION_OPS = Object.keys(CONDITION_OP_LABEL);
/**
* Read the repeating condition rows back off a submitted form.
*
* One indexed name per row (`condition_field_0`, `condition_op_0`,
* `condition_value_0`, then `_1`, and so on), read by index. This lives here
* rather than in the route's `+page.server.js` so the parse and the clean can
* be tested together: the harness covers `$lib/server/` modules and cannot
* import a route module at all.
*
* The indexing is the whole point, and it replaced three parallel `getAll`
* arrays that were paired POSITIONALLY. A `<select>` whose value matches no
* option has `selectedIndex === -1`, and such a select contributes NO entry to
* `FormData`. With parallel arrays one missing `condition_field` therefore
* shifted every later row's operator and value up by one, rewriting conditions
* the admin never touched into ones that match nothing, or dropping the last
* row entirely and widening the rule. The page also renders an unmatched
* stored field as its own option so the select has something to match, but
* these two fixes are not redundant: that one keeps a known value editable,
* this one means no future unmatched value can corrupt its neighbours.
*
* Indices come from the submitted keys rather than from a hidden count field,
* so a row that submits only two of its three inputs still holds its place,
* and a missing count cannot silently collapse every condition into "matches
* every ticket".
*
* @param {FormData} form
* @returns {{ field: string, op: string, value: string }[]}
*/
export function readConditionRows(form) {
/** @type {Map<number, { field: string, op: string, value: string }>} */
const rows = new Map();
for (const key of form.keys()) {
const match = /^condition_(field|op|value)_(\d+)$/.exec(key);
if (!match) continue;
const index = Number(match[2]);
let row = rows.get(index);
if (!row) {
row = { field: '', op: 'eq', value: '' };
rows.set(index, row);
}
const part = /** @type {'field' | 'op' | 'value'} */ (match[1]);
const raw = form.get(key);
if (raw !== null) row[part] = raw.toString();
}
return [...rows.entries()].sort((a, b) => a[0] - b[0]).map(([, row]) => row);
}
/**
* Clean the repeating condition rows into what the backend validates.
*
* A row the admin added and left blank is dropped, not rejected: an empty row
* is how the editor represents "I am about to type here", and failing the save
* for it would be hostile. A row with a field but no operator gets `eq`, the
* backend's own default. `value` is always sent even when empty, including as
* an empty array for `in`, because `validate_conditions` requires the KEY to
* be present and only checks `"value" not in cond`.
*
* `in` gets an array; every other op keeps the plain string it always has.
* `cases/routing.py`'s evaluator does
* `if not isinstance(value, (list, tuple)): return False` for `in`, and does
* it silently: the unknown-op and unknown-field branches next to it both log
* a warning, this one does not. `validate_conditions` never checks the
* value's type, only that the key exists, so a string here would save
* cleanly (200) and then match nothing, forever, with no signal anywhere
* pointing at why. A comma-separated string is split and trimmed; a value
* that already arrives as an array (a condition this editor did not
* originate) is kept as one, each entry trimmed the same way.
*/
function cleanConditions(rows) {
return (rows ?? [])
.map((r) => {
const op = String(r?.op ?? 'eq').trim() || 'eq';
const raw = r?.value;
let value;
if (op === 'in') {
const parts = Array.isArray(raw)
? raw.map((v) => String(v).trim())
: String(raw ?? '')
.split(',')
.map((v) => v.trim());
value = parts.filter(Boolean);
} else {
value = raw === undefined || raw === null ? '' : String(raw);
}
return { field: String(r?.field ?? '').trim(), op, value };
})
.filter((r) => r.field)
.map((r) => {
if (!(CONDITION_FIELDS.includes(r.field) || r.field.startsWith('custom_fields.'))) {
throw new Error(`"${r.field}" is not a field a rule can match on.`);
}
if (!CONDITION_OPS.includes(r.op)) {
throw new Error(`"${r.op}" is not an operator a rule can use.`);
}
return r;
});
}
/** @param {string[]} allowed @param {{ [key: string]: any }} values */
function buildBody(allowed, values) {
/** @type {Record<string, any>} */
const body = {};
for (const field of allowed) {
if (values[field] === undefined) continue;
body[field] = values[field];
}
if (body.priority_order !== undefined) body.priority_order = Number(body.priority_order) || 0;
for (const flag of ['is_active', 'stop_processing']) {
if (body[flag] !== undefined) body[flag] = Boolean(body[flag]);
}
if (body.conditions !== undefined) body.conditions = cleanConditions(body.conditions);
// `by_team` routes to a team and nothing else; the other three route to
// people. Sending the half that does not apply is not rejected by the
// backend, it is simply stored, and a later strategy switch would then
// resurrect a stale target nobody chose. Send only the half in play.
if (body.strategy === 'by_team') {
delete body.target_assignee_ids;
} else if (body.strategy !== undefined) {
delete body.target_team_id;
}
// An empty string from an unselected `<select>` is not null. The field is
// `allow_null=True`, so null clears it; `''` is a 400.
if (body.target_team_id === '') body.target_team_id = null;
if (body.target_assignee_ids !== undefined) {
const ids = (body.target_assignee_ids ?? []).filter(Boolean);
// An empty multi-select omits the key rather than sending `[]`, because
// `[]` disarms the backend's own guard. `RoutingRuleSerializer.validate`
// reads `assignees = attrs.get("target_assignees")` and only raises when
// `assignees is None and self.instance is None`; `[]` is not `None`, so a
// create with a condition and nobody selected returns 201. With
// `stop_processing` on by default, `cases/routing.py` then hits
// `if not pool: decision.reason = "empty_pool"` followed by
// `if rule.stop_processing: return decision`, so every matching ticket is
// left unassigned by a rule that also stops any lower rule from assigning
// it. Omitting the key lets the serializer answer with its own message.
//
// Omitting it is only half the answer, and only on create. `put` is
// `partial=True`, so on an edit the omission leaves the stored assignees
// in place, and the backend has no update-path guard to speak up
// (`self.instance is None` is false there). An admin who deselected
// everyone would get a success, a closed panel, and the old list back,
// with nothing saying the change was dropped. `updateRoutingRule` refuses
// that request outright instead: see the guard below it.
if (ids.length === 0) delete body.target_assignee_ids;
else body.target_assignee_ids = ids;
}
return body;
}
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function createRoutingRule({ cookies }, values) {
const body = buildBody(CREATE_FIELDS, values);
if (!body.name) throw new Error('A rule needs a name.');
return await apiRequest('/cases/routing-rules/', { method: 'POST', body }, { cookies });
}
/** The strategies that route to people rather than to a team, which is every
* strategy except `by_team`. Derived from the same map the page labels its
* select from, rather than restated, for the reason `CONDITION_FIELDS` is
* derived: two hand-maintained copies of one set drift. It matches
* `RoutingRuleSerializer.validate`'s `("round_robin", "least_busy",
* "direct")`. All four strategies are exercised against the guard below, so
* a change to that map which no longer splits the same way fails there. */
const PEOPLE_STRATEGIES = Object.keys(ROUTING_STRATEGY_NAME).filter((s) => s !== 'by_team');
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function updateRoutingRule({ cookies }, id, values) {
if (!id) throw new Error('Which rule? No rule id was given.');
const body = buildBody(UPDATE_FIELDS, values);
// The one rule the backend enforces on create and cannot enforce on edit.
// `RoutingRuleSerializer.validate` guards a people strategy with no
// assignees behind `self.instance is None`, so on a PUT it never fires, and
// `buildBody` above has just dropped the empty list so `partial=True` will
// leave the stored assignees alone. Sending it would answer 200 and change
// nothing the admin asked to change, which is a silent no-op on a control
// they just operated. Refuse instead, and say which constraint refused.
//
// Both conditions are required. `body.strategy` alone is not enough: a
// partial update that names a strategy without touching the assignee
// picker (or the turn-on path, which sends `{ is_active: true }` and
// nothing else) must still go through untouched. And an empty list under
// `by_team` cannot reach here at all, because `buildBody` deletes the
// assignee key outright for that strategy before the emptiness check.
const submittedAssignees = values?.target_assignee_ids !== undefined;
if (
submittedAssignees &&
body.target_assignee_ids === undefined &&
PEOPLE_STRATEGIES.includes(body.strategy)
) {
const name = ROUTING_STRATEGY_NAME[body.strategy].toLowerCase();
throw new Error(`A ${name} rule needs at least one assignee.`);
}
return await apiRequest(`/cases/routing-rules/${id}/`, { method: 'PUT', body }, { cookies });
}
/**
* Delete a rule, permanently.
*
* `RoutingRuleDetailView.delete` calls `obj.delete()`. Unlike
* `/custom-fields/<id>/`, there is no soft-delete hiding behind this verb, so
* the control that calls it has to say so.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
*/
export async function deleteRoutingRule({ cookies }, id) {
if (!id) throw new Error('Which rule? No rule id was given.');
return await apiRequest(`/cases/routing-rules/${id}/`, { method: 'DELETE' }, { cookies });
}

View file

@ -0,0 +1,537 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const {
createRoutingRule,
updateRoutingRule,
deleteRoutingRule,
readConditionRows,
CONDITION_FIELDS,
CONDITION_OPS
} = await import('./routing.js');
const cookies = /** @type {any} */ ({ get: () => 'token' });
const event = /** @type {any} */ ({ cookies });
const base = {
name: 'Urgent to Ada',
priority_order: '5',
is_active: true,
stop_processing: true,
strategy: 'direct',
target_assignee_ids: ['p1'],
target_team_id: '',
conditions: [{ field: 'priority', op: 'eq', value: 'Urgent' }]
};
describe('createRoutingRule', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('sends only allow-listed keys on create', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
org: 'ATTACKER-ORG',
created_by: 'ATTACKER'
});
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/routing-rules/');
expect(opts.method).toBe('POST');
expect(Object.keys(opts.body).sort()).toEqual([
'conditions',
'is_active',
'name',
'priority_order',
'stop_processing',
'strategy',
'target_assignee_ids'
]);
expect(opts.body.priority_order).toBe(5);
expect(opts.body.org).toBeUndefined();
expect(opts.body.created_by).toBeUndefined();
});
it('coerces priority_order to a number', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, { ...base, priority_order: '12' });
expect(apiRequest.mock.calls[0][1].body.priority_order).toBe(12);
expect(typeof apiRequest.mock.calls[0][1].body.priority_order).toBe('number');
});
it('falls back to 0 for a non-numeric priority_order', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, { ...base, priority_order: 'not-a-number' });
expect(apiRequest.mock.calls[0][1].body.priority_order).toBe(0);
});
it('coerces is_active and stop_processing to booleans', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
is_active: 'true',
stop_processing: ''
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.is_active).toBe(true);
expect(typeof body.is_active).toBe('boolean');
expect(body.stop_processing).toBe(false);
expect(typeof body.stop_processing).toBe('boolean');
});
it('drops condition rows with a blank field', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
conditions: [
{ field: '', op: 'eq', value: 'ignored' },
{ field: ' ', op: 'eq', value: 'also ignored' },
{ field: 'priority', op: 'eq', value: 'Urgent' }
]
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.conditions).toEqual([{ field: 'priority', op: 'eq', value: 'Urgent' }]);
});
it('still sends a value key when a row has a field but no value key', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
conditions: [{ field: 'priority', op: 'eq' }]
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.conditions).toEqual([{ field: 'priority', op: 'eq', value: '' }]);
});
it('defaults a condition row missing an operator to eq', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
conditions: [{ field: 'priority', op: '', value: 'Urgent' }]
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.conditions).toEqual([{ field: 'priority', op: 'eq', value: 'Urgent' }]);
});
it('splits an "in" condition value on commas into a trimmed array', async () => {
// `cases/routing.py`'s evaluator requires a list/tuple for `in` and
// silently returns False for every ticket otherwise. A comma-separated
// string is the only way one text input can express a list.
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
conditions: [{ field: 'priority', op: 'in', value: 'Urgent, High ,Medium' }]
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.conditions).toEqual([
{ field: 'priority', op: 'in', value: ['Urgent', 'High', 'Medium'] }
]);
});
it('keeps an already-array "in" condition value as an array', async () => {
// A rule whose `in` condition was seeded or created directly through the
// API (not through this editor) already carries an array. Editing an
// unrelated field on that same rule must not turn it into a string.
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
conditions: [{ field: 'priority', op: 'in', value: ['Urgent', 'High'] }]
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.conditions).toEqual([{ field: 'priority', op: 'in', value: ['Urgent', 'High'] }]);
});
it('turns an empty "in" condition value into an empty array, and still sends the key', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
conditions: [{ field: 'priority', op: 'in' }]
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.conditions).toEqual([{ field: 'priority', op: 'in', value: [] }]);
});
it('does not split an "eq" condition value that contains a comma', async () => {
// A tag or account name can legitimately contain a comma. Only `in`
// means "list"; every other operator's value is a literal string.
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
conditions: [{ field: 'tags', op: 'eq', value: 'North America, EMEA' }]
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.conditions).toEqual([{ field: 'tags', op: 'eq', value: 'North America, EMEA' }]);
});
it('rejects a condition field the backend does not accept, before the request', async () => {
await expect(
createRoutingRule(event, {
...base,
conditions: [{ field: 'severity', op: 'eq', value: 'high' }]
})
).rejects.toThrow(/not a field a rule can match on/i);
expect(apiRequest).not.toHaveBeenCalled();
});
it('accepts a custom_fields.-prefixed condition field', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
conditions: [{ field: 'custom_fields.severity', op: 'eq', value: 'high' }]
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.conditions).toEqual([{ field: 'custom_fields.severity', op: 'eq', value: 'high' }]);
});
it('rejects a condition operator the backend does not accept, before the request', async () => {
await expect(
createRoutingRule(event, {
...base,
conditions: [{ field: 'priority', op: 'startswith', value: 'high' }]
})
).rejects.toThrow(/not an operator a rule can use/i);
expect(apiRequest).not.toHaveBeenCalled();
});
it('drops target_team_id for a non-by_team strategy', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, { ...base, strategy: 'round_robin', target_team_id: 't1' });
expect(apiRequest.mock.calls[0][1].body.target_team_id).toBeUndefined();
});
it('drops target_assignee_ids and keeps target_team_id for by_team', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
strategy: 'by_team',
target_team_id: 't1',
target_assignee_ids: ['p1', 'p2']
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.target_assignee_ids).toBeUndefined();
expect(body.target_team_id).toBe('t1');
});
it('turns an empty-string target_team_id into null for by_team', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, { ...base, strategy: 'by_team', target_team_id: '' });
expect(apiRequest.mock.calls[0][1].body.target_team_id).toBeNull();
});
it('filters falsy entries out of target_assignee_ids', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
strategy: 'direct',
target_assignee_ids: ['p1', '', null, 'p2']
});
expect(apiRequest.mock.calls[0][1].body.target_assignee_ids).toEqual(['p1', 'p2']);
});
it('omits target_assignee_ids entirely when nobody is selected', async () => {
// `[]` is not `None`, and `RoutingRuleSerializer.validate` only raises
// when `assignees is None and self.instance is None`. Sending `[]` from an
// empty multi-select therefore disarms the backend's own create guard: the
// rule saves 201 with an empty pool, and with `stop_processing` on it eats
// every matching ticket and assigns nobody. Omitting the key is what lets
// the serializer answer.
apiRequest.mockResolvedValue({});
await createRoutingRule(event, { ...base, strategy: 'direct', target_assignee_ids: [] });
const { body } = apiRequest.mock.calls[0][1];
expect('target_assignee_ids' in body).toBe(false);
});
it('omits target_assignee_ids when every selected entry is blank', async () => {
apiRequest.mockResolvedValue({});
await createRoutingRule(event, {
...base,
strategy: 'round_robin',
target_assignee_ids: ['', null, undefined]
});
const { body } = apiRequest.mock.calls[0][1];
expect('target_assignee_ids' in body).toBe(false);
});
it('refuses a rule with no name before making a request', async () => {
await expect(createRoutingRule(event, { ...base, name: '' })).rejects.toThrow(/needs a name/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('updateRoutingRule', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('PUTs to the detail endpoint', async () => {
apiRequest.mockResolvedValue({});
await updateRoutingRule(event, 'r1', base);
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/routing-rules/r1/');
expect(opts.method).toBe('PUT');
});
it('omits nothing and adds nothing outside UPDATE_FIELDS on a full edit', async () => {
apiRequest.mockResolvedValue({});
await updateRoutingRule(event, 'r1', {
...base,
strategy: 'round_robin',
org: 'ATTACKER-ORG',
created_by: 'ATTACKER'
});
const { body } = apiRequest.mock.calls[0][1];
expect(Object.keys(body).sort()).toEqual([
'conditions',
'is_active',
'name',
'priority_order',
'stop_processing',
'strategy',
'target_assignee_ids'
]);
expect(body.org).toBeUndefined();
expect(body.created_by).toBeUndefined();
});
it('sends exactly one key for a minimal { is_active: true } body', async () => {
apiRequest.mockResolvedValue({});
await updateRoutingRule(event, 'r1', { is_active: true });
const { body } = apiRequest.mock.calls[0][1];
expect(body).toEqual({ is_active: true });
});
it('sends exactly one key for a minimal { is_active: false } body', async () => {
apiRequest.mockResolvedValue({});
await updateRoutingRule(event, 'r1', { is_active: false });
const { body } = apiRequest.mock.calls[0][1];
expect(body).toEqual({ is_active: false });
});
it('refuses an edit that leaves a people strategy with no assignee', async () => {
// The backend cannot answer this one. Its guard is behind
// `self.instance is None`, and `put` is `partial=True`, so the request
// would return 200 and leave the stored assignees exactly as they were:
// a success for a change that did not happen, on a control the admin just
// operated. Refused here, before the round trip.
await expect(
updateRoutingRule(event, 'r1', { ...base, strategy: 'direct', target_assignee_ids: [] })
).rejects.toThrow(/direct rule needs at least one assignee/i);
expect(apiRequest).not.toHaveBeenCalled();
});
it('names the strategy that refused, for each people strategy', async () => {
await expect(
updateRoutingRule(event, 'r1', { ...base, strategy: 'round_robin', target_assignee_ids: [] })
).rejects.toThrow(/round robin rule needs at least one assignee/i);
await expect(
updateRoutingRule(event, 'r1', { ...base, strategy: 'least_busy', target_assignee_ids: [] })
).rejects.toThrow(/least busy rule needs at least one assignee/i);
expect(apiRequest).not.toHaveBeenCalled();
});
it('refuses an edit whose assignee entries are all blank', async () => {
await expect(
updateRoutingRule(event, 'r1', {
...base,
strategy: 'direct',
target_assignee_ids: ['', null]
})
).rejects.toThrow(/needs at least one assignee/i);
expect(apiRequest).not.toHaveBeenCalled();
});
it('allows an edit switching to by_team with an empty people list', async () => {
// This path must stay open: switching a rule to by_team is exactly when
// the people multi-select is empty and should be. `buildBody` drops the
// assignee key for `by_team` before the emptiness check, so the guard
// never sees it.
apiRequest.mockResolvedValue({});
await updateRoutingRule(event, 'r1', {
...base,
strategy: 'by_team',
target_team_id: 't1',
target_assignee_ids: []
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.target_team_id).toBe('t1');
expect('target_assignee_ids' in body).toBe(false);
});
it('allows a partial edit that never submitted an assignee list', async () => {
// A caller that names a strategy without touching the picker is asking to
// leave the assignees alone, which `partial=True` does. Only a submitted
// and empty list is a discarded change.
apiRequest.mockResolvedValue({});
await updateRoutingRule(event, 'r1', { name: 'Renamed', strategy: 'direct' });
const { body } = apiRequest.mock.calls[0][1];
expect(body).toEqual({ name: 'Renamed', strategy: 'direct' });
});
it('leaves the turn-on and turn-off paths untouched by the guard', async () => {
apiRequest.mockResolvedValue({});
await updateRoutingRule(event, 'r1', { is_active: true });
await updateRoutingRule(event, 'r1', { is_active: false });
expect(apiRequest).toHaveBeenCalledTimes(2);
});
it('does not refuse a create with the same empty list', async () => {
// Deliberately asymmetric. On create the backend's own guard fires and
// its message is the better one, which is the whole point of omitting the
// key rather than sending `[]`.
apiRequest.mockResolvedValue({});
await createRoutingRule(event, { ...base, strategy: 'direct', target_assignee_ids: [] });
expect(apiRequest).toHaveBeenCalledTimes(1);
});
it('throws when the id is missing', async () => {
await expect(updateRoutingRule(event, '', base)).rejects.toThrow(/which rule/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('deleteRoutingRule', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('DELETEs the detail endpoint', async () => {
apiRequest.mockResolvedValue({});
await deleteRoutingRule(event, 'r1');
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/routing-rules/r1/');
expect(opts.method).toBe('DELETE');
});
it('throws when the id is missing', async () => {
await expect(deleteRoutingRule(event, '')).rejects.toThrow(/which rule/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('CONDITION_FIELDS and CONDITION_OPS', () => {
// These are derived from `CONDITION_FIELD_LABEL`/`CONDITION_OP_LABEL` in
// `$lib/v2/enums.js` rather than hand-restated, because the page's selects
// read those maps directly (a `.svelte` file cannot import from
// `$lib/server/`) and two hand-maintained copies of the same set drift.
// This pins the derived lists to exactly what
// `RoutingRuleSerializer.validate_conditions` accepts, so an addition to
// the label maps that the backend does not also accept fails here instead
// of shipping a field or operator the page offers and the API 400s.
it('matches the fields RoutingRuleSerializer.validate_conditions accepts', () => {
expect(CONDITION_FIELDS).toEqual([
'priority',
'case_type',
'account',
'tags',
'from_email_domain',
'mailbox_id'
]);
});
it('matches the operators RoutingRuleSerializer.validate_conditions accepts', () => {
expect(CONDITION_OPS).toEqual(['eq', 'in', 'contains', 'regex']);
});
});
describe('readConditionRows', () => {
/**
* One row's three inputs, as the form submits them.
*
* `omit` names a control that submitted nothing, which is what a `<select>`
* with no matching option does: `selectedIndex` is -1 and the browser
* contributes no entry for it at all.
*
* @param {FormData} form
* @param {number} i
* @param {{ field?: string, op?: string, value?: string }} row
*/
function appendRow(form, i, row) {
if (row.field !== undefined) form.append(`condition_field_${i}`, row.field);
if (row.op !== undefined) form.append(`condition_op_${i}`, row.op);
if (row.value !== undefined) form.append(`condition_value_${i}`, row.value);
}
beforeEach(() => {
apiRequest.mockReset();
});
it('pairs each row by its index', () => {
const form = new FormData();
appendRow(form, 0, { field: 'priority', op: 'eq', value: 'Urgent' });
appendRow(form, 1, { field: 'tags', op: 'in', value: 'vip, gold' });
expect(readConditionRows(form)).toEqual([
{ field: 'priority', op: 'eq', value: 'Urgent' },
{ field: 'tags', op: 'in', value: 'vip, gold' }
]);
});
it('does not shift the next row when a row submits no field', () => {
// The defect this indexing replaced: three parallel `getAll` arrays paired
// POSITIONALLY, so one absent `condition_field` moved every later row's
// operator and value up by one. Here row 1 keeps its own op and value.
const form = new FormData();
appendRow(form, 0, { op: 'eq', value: 'EMEA' });
appendRow(form, 1, { field: 'priority', op: 'in', value: 'High, Urgent' });
expect(readConditionRows(form)).toEqual([
{ field: '', op: 'eq', value: 'EMEA' },
{ field: 'priority', op: 'in', value: 'High, Urgent' }
]);
});
it('reads rows in index order however the entries were appended', () => {
const form = new FormData();
appendRow(form, 2, { field: 'account', op: 'eq', value: 'Acme' });
appendRow(form, 0, { field: 'priority', op: 'eq', value: 'Urgent' });
expect(readConditionRows(form).map((r) => r.field)).toEqual(['priority', 'account']);
});
it('ignores form keys that are not indexed condition inputs', () => {
const form = new FormData();
form.append('name', 'Urgent to Ada');
form.append('condition_field', 'priority');
form.append('condition_field_x', 'priority');
appendRow(form, 0, { field: 'tags', op: 'eq', value: 'vip' });
expect(readConditionRows(form)).toEqual([{ field: 'tags', op: 'eq', value: 'vip' }]);
});
it('carries a custom_fields. condition through an edit unchanged', async () => {
// The whole round trip: the page renders a stored `custom_fields.<key>`
// as its own option so the select has something to match, the form
// submits it under an indexed name, and the rule saves with the same two
// conditions it had. Before this, an admin who opened Edit only to rename
// a rule lost the custom-field condition and shifted the other row.
apiRequest.mockResolvedValue({});
const form = new FormData();
appendRow(form, 0, { field: 'custom_fields.region', op: 'eq', value: 'EMEA' });
appendRow(form, 1, { field: 'priority', op: 'in', value: 'High, Urgent' });
await updateRoutingRule(event, 'r1', {
name: 'Renamed, conditions untouched',
strategy: 'direct',
target_assignee_ids: ['p1'],
conditions: readConditionRows(form)
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.conditions).toEqual([
{ field: 'custom_fields.region', op: 'eq', value: 'EMEA' },
{ field: 'priority', op: 'in', value: ['High', 'Urgent'] }
]);
});
it('drops only the row that submitted no field, leaving the rest intact', async () => {
apiRequest.mockResolvedValue({});
const form = new FormData();
appendRow(form, 0, { op: 'eq', value: 'EMEA' });
appendRow(form, 1, { field: 'priority', op: 'in', value: 'High, Urgent' });
await updateRoutingRule(event, 'r1', {
name: 'Renamed',
strategy: 'direct',
target_assignee_ids: ['p1'],
conditions: readConditionRows(form)
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.conditions).toEqual([{ field: 'priority', op: 'in', value: ['High', 'Urgent'] }]);
});
});

View file

@ -8,19 +8,77 @@
* `{ count, active, unused }` for the stat cards. Both are computed server-side
* (the usage counts are org-scoped subqueries, not a client tally over rows).
*
* Read-only page: the only controls ("New tag", the duplicate-merge banner) are
* not wired, deferred, like the other settings builders. No write path here.
* "New tag" is wired: `createTag` below posts the name and the page's action
* calls it. The duplicate-merge banner is still deferred; its "Merge" button
* has no backing endpoint yet, see the comment on it in `+page.svelte`.
*
* CREATE IS ADMIN-ONLY, LIST IS NOT
* `/settings` is deliberately member-readable (see the comment in
* `$lib/v2/components/Sidebar.svelte`), and `TagsListView.get` has no role
* check, so every member reads the full tag list. `TagsListView.post`
* (`backend/common/views/tags_views.py:143-149`) does gate: it 403s anyone
* whose `profile.role` is not `ADMIN`. `can_edit` below mirrors that split so
* the page can hide "New tag" for a member instead of letting them submit a
* form the backend was always going to refuse. It is a display hint only,
* decoded from the JWT and never verified; the backend re-derives the role
* from the same token and is the check that actually matters. `viewerRole` is
* imported from `organization.js`, where it is exported for exactly this
* reuse, rather than redefined here: a role check is exactly the kind of
* thing that should not exist in one more private copy per module.
* `goals.js` and `products.js` still carry their own pre-existing private
* copies of the same decode; not touched here, worth consolidating later.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { viewerRole } from './organization.js';
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ tags: any[], totals: any }>}
* @returns {Promise<{ tags: any[], totals: any, can_edit: boolean }>}
*/
export async function getTags({ cookies }) {
const resp = await apiRequest('/tags/?include_archived=true', {}, { cookies });
return {
tags: resp.tags ?? [],
totals: resp.totals ?? { count: 0, active: 0, unused: 0 }
totals: resp.totals ?? { count: 0, active: 0, unused: 0 },
can_edit: viewerRole(cookies) === 'ADMIN'
};
}
/**
* Create a tag.
*
* Only `name` is sent. `org` comes from the JWT on the backend, and `usage`
* and `totals` are computed, never submitted. The empty-name guard is a fast
* fail for an obvious mistake, not a security control: `TagsListView.post`
* (`backend/common/views/tags_views.py`) runs its own `if not name:` check
* after stripping the param, and that is the real authority. `TagsSerializer`
* is not in this path at all; the view only uses it to shape the response.
*
* `TagsListView.post` wraps its result in an envelope,
* `{ error, message, tag }`, on both the create branch and the reactivation
* branch (an archived tag with the same slug is revived instead of
* duplicated). This unwraps to `.tag` deliberately rather than just fixing
* the doc comment: a function named `createTag` that resolves to the
* envelope is a trap the next caller will fall into blind, exactly like
* `createLead` does today, `LeadListView.post` returns no id at all, so
* `leads/new/+page.server.js`'s `created?.id` is always undefined and every
* lead creation redirects to `/leads` instead of the new record. Not every
* create endpoint in this codebase gives you a record back; the seven
* settings pages that copy this function will want to link to or highlight
* the tag they just made, so this one hands back the real thing.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {{ name?: string, [key: string]: any }} values callers may pass extra
* keys (a hostile `org`, say); the index signature lets TypeScript accept
* the object literal without widening what this function actually reads,
* which stays just `name`.
* @returns {Promise<any>} the created (or reactivated) tag record: `{ id, name,
* slug, color, description, is_active, created_at }`.
*/
export async function createTag({ cookies }, values) {
const name = (values.name ?? '').trim();
if (!name) throw new Error('A tag needs a name.');
const resp = await apiRequest('/tags/', { method: 'POST', body: { name } }, { cookies });
return resp.tag ?? resp;
}

View file

@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { createTag } = await import('$lib/server/v2/tags.js');
// Cast rather than shaping a full Cookies mock, matching leads.test.js:
// createTag only ever calls `cookies.get`, and `apiRequest` itself is mocked
// above, so nothing here touches `getAll`/`set`/`delete`/`serialize`. Without
// the cast svelte-check flags this object against SvelteKit's full `Cookies`
// type on every call site below, which is noise for a shape the test
// deliberately keeps minimal.
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
describe('createTag', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('POSTs the trimmed name to /tags/ and unwraps the tag from the response envelope', async () => {
// The real backend, `TagsListView.post`, never returns a bare tag: it
// wraps its result as `{ error, message, tag }` on both the create
// branch and the reactivation branch. A mock that returned a bare object
// here would let `resp.tag ?? resp`'s fallback mask a broken unwrap.
apiRequest.mockResolvedValue({
error: false,
message: 'Tag Created Successfully',
tag: { id: 't1', name: 'urgent' }
});
const result = await createTag(event, { name: ' urgent ' });
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/tags/');
expect(options.method).toBe('POST');
expect(options.body.name).toBe('urgent');
expect(result.id).toBe('t1');
expect(result.name).toBe('urgent');
});
it('falls back to the raw response when it is not wrapped in a tag envelope', async () => {
apiRequest.mockResolvedValue({ id: 't1', name: 'urgent' });
const result = await createTag(event, { name: 'urgent' });
expect(result.id).toBe('t1');
});
it('rejects an empty name before making a request', async () => {
await expect(createTag(event, { name: ' ' })).rejects.toThrow(/name/i);
expect(apiRequest).not.toHaveBeenCalled();
});
it('never forwards a client-supplied org', async () => {
apiRequest.mockResolvedValue({ tag: { id: 't1' } });
await createTag(event, { name: 'urgent', org: 'attacker-org' });
expect(apiRequest.mock.calls[0][1].body.org).toBeUndefined();
});
});

View file

@ -7,16 +7,23 @@
* Approval rows in state "pending" bound to it). Totals carry `count`, `active`,
* and `pending` (the org's total waiting approvals).
*
* Read-only page: "New rule" is a deferred builder, so there is no write path.
* The backend returns `approvers` as `[{ id, email }]`; the page renders them as
* a sentence (`approvers.join(' or ')`), so flatten to the email strings here.
* `match_team` already arrives as `{ id, name }` or null.
* Create, edit, turn off, turn on and delete are wired below. Delete is not
* what its name says on this resource: it destroys a rule that has never been
* used and turns off one that has, and it answers 2xx either way. See
* `deleteApprovalRule` for how the two are told apart.
*
* The backend
* returns `approvers` as `[{ id, email }]`; that shape is kept as-is rather than
* flattened to bare email strings, because the edit form needs the ids to
* preselect the approver multi-select. The page reads `.email` off each entry
* for the sentence it renders on the row.
*/
import { apiRequest } from '$lib/api-helpers.js';
import { viewerRole } from './organization.js';
/**
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ rules: any[], totals: any }>}
* @returns {Promise<{ rules: any[], totals: any, can_edit: boolean }>}
*/
export async function getApprovalRules({ cookies }) {
const { rules, totals } = await apiRequest('/cases/approval-rules/', {}, { cookies });
@ -26,12 +33,119 @@ export async function getApprovalRules({ cookies }) {
name: r.name,
is_active: r.is_active,
approver_role: r.approver_role,
approvers: (r.approvers ?? []).map((a) => a.email).filter(Boolean),
approvers: (r.approvers ?? []).filter((a) => a && a.id),
match_priority: r.match_priority,
match_case_type: r.match_case_type,
match_team: r.match_team ?? null,
pending_count: r.pending_count ?? 0
})),
totals: totals ?? { count: 0, active: 0, pending: 0 }
totals: totals ?? { count: 0, active: 0, pending: 0 },
// A display hint: POST/PUT/DELETE on `/cases/approval-rules/` each start
// with `_is_admin(request.profile)` server-side and 403 regardless of
// what this says. This only decides whether the page offers the controls.
can_edit: viewerRole(cookies) === 'ADMIN'
};
}
/** Everything a create or edit may set. `org` and `created_by` are the JWT's,
* never the body's, and `trigger_event` is not here: see `TRIGGER_EVENT`
* below. Nothing is frozen after creation on this resource, so an edit may
* set the same fields a create may; `ApprovalRuleDetailView.put` builds its
* serializer with `partial=True`, so an omitted key is left untouched rather
* than blanked, which is what lets the turn-on action send a single key. */
const CREATE_FIELDS = [
'name',
'match_priority',
'match_case_type',
'match_team_id',
'approver_role',
'approver_ids',
'is_active'
];
const UPDATE_FIELDS = CREATE_FIELDS;
/** The only value `trigger_event` accepts. It is set here rather than read
* from the form: a select with one option is a control that cannot be
* wrong, so it should not be a control. If the backend ever gains a second
* trigger, this is the one line that has to change and the field becomes a
* real select. */
const TRIGGER_EVENT = 'pre_close';
/** @param {string[]} allowed @param {{ [key: string]: any }} values */
function buildBody(allowed, values) {
/** @type {Record<string, any>} */
const body = {};
for (const field of allowed) {
if (values[field] === undefined) continue;
body[field] = values[field];
}
// Three nullable fields whose "any" option is an empty `<option>` value.
// `''` fails the ChoiceField and the PK lookup alike; `null` is what clears
// them, and all three are `allow_null=True`.
for (const nullable of ['match_priority', 'match_case_type', 'match_team_id']) {
if (body[nullable] === '') body[nullable] = null;
}
if (body.is_active !== undefined) body.is_active = Boolean(body.is_active);
if (body.approver_ids !== undefined) {
body.approver_ids = (body.approver_ids ?? []).filter(Boolean);
}
return body;
}
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function createApprovalRule({ cookies }, values) {
const body = buildBody(CREATE_FIELDS, values);
if (!body.name) throw new Error('A rule needs a name.');
body.trigger_event = TRIGGER_EVENT;
return await apiRequest('/cases/approval-rules/', { method: 'POST', body }, { cookies });
}
/** @param {{ cookies: import('@sveltejs/kit').Cookies }} event */
export async function updateApprovalRule({ cookies }, id, values) {
if (!id) throw new Error('Which rule? No rule id was given.');
const body = buildBody(UPDATE_FIELDS, values);
return await apiRequest(`/cases/approval-rules/${id}/`, { method: 'PUT', body }, { cookies });
}
/**
* Delete a rule, or turn it off, depending on what the backend decides.
*
* There IS a soft-delete hiding behind this verb, and an earlier version of
* this comment said the opposite. `ApprovalRuleDetailView.delete` branches:
*
* ```python
* if rule.requests.exists():
* rule.is_active = False
* rule.save(update_fields=["is_active", "updated_at"])
* return Response({"id": str(rule.id), "is_active": False}, status=200)
* rule.delete()
* ```
*
* `Approval.rule` is `on_delete=models.PROTECT`, so a rule that has ever gated
* a close cannot be destroyed without taking the approval history with it. The
* view turns it off instead and still answers 2xx, so the caller cannot read
* the outcome off "did it throw".
*
* Two things make the outcome unguessable from the page's own data:
*
* - `rule.requests.exists()` counts approvals in EVERY state. The
* `pending_count` the page already has counts only `state="pending"`, so a
* rule showing zero pending can still have history and still be soft
* disabled.
* - The two branches differ only in their body. `apiRequest` returns the
* parsed body on 200 and `null` on the hard-delete branch's 204, so
* `is_active === false` in the response is the signal, and the absence of a
* body is the other one.
*
* Hence the return shape: the action that calls this reports what happened
* rather than asserting what it hoped for.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @returns {Promise<{ turned_off: boolean }>} `turned_off` is true when the
* rule had history and was disabled rather than destroyed.
*/
export async function deleteApprovalRule({ cookies }, id) {
if (!id) throw new Error('Which rule? No rule id was given.');
const resp = await apiRequest(`/cases/approval-rules/${id}/`, { method: 'DELETE' }, { cookies });
return { turned_off: resp?.is_active === false };
}

View file

@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { createApprovalRule, updateApprovalRule, deleteApprovalRule } = await import(
'./ticket-approvals.js'
);
const cookies = /** @type {any} */ ({ get: () => 'token' });
const event = /** @type {any} */ ({ cookies });
const base = {
name: 'Urgent close needs a manager',
match_priority: 'Urgent',
match_case_type: 'Incident',
match_team_id: 't1',
approver_role: 'ADMIN',
approver_ids: ['p1', 'p2'],
is_active: true
};
describe('createApprovalRule', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('sends only allow-listed keys on create and drops a hostile org', async () => {
apiRequest.mockResolvedValue({});
await createApprovalRule(event, {
...base,
org: 'ATTACKER-ORG',
created_by: 'ATTACKER'
});
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/approval-rules/');
expect(opts.method).toBe('POST');
expect(Object.keys(opts.body).sort()).toEqual([
'approver_ids',
'approver_role',
'is_active',
'match_case_type',
'match_priority',
'match_team_id',
'name',
'trigger_event'
]);
expect(opts.body.org).toBeUndefined();
expect(opts.body.created_by).toBeUndefined();
});
it('always sends trigger_event as pre_close, ignoring any caller-supplied value', async () => {
apiRequest.mockResolvedValue({});
await createApprovalRule(event, { ...base, trigger_event: 'post_close' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.trigger_event).toBe('pre_close');
});
it('sends null, not an empty string, for an unselected match_priority', async () => {
apiRequest.mockResolvedValue({});
await createApprovalRule(event, { ...base, match_priority: '' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.match_priority).toBeNull();
});
it('sends null, not an empty string, for an unselected match_case_type', async () => {
apiRequest.mockResolvedValue({});
await createApprovalRule(event, { ...base, match_case_type: '' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.match_case_type).toBeNull();
});
it('sends null, not an empty string, for an unselected match_team_id', async () => {
apiRequest.mockResolvedValue({});
await createApprovalRule(event, { ...base, match_team_id: '' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.match_team_id).toBeNull();
});
it('filters blank entries out of approver_ids', async () => {
apiRequest.mockResolvedValue({});
await createApprovalRule(event, {
...base,
approver_ids: ['p1', '', null, 'p2']
});
const { body } = apiRequest.mock.calls[0][1];
expect(body.approver_ids).toEqual(['p1', 'p2']);
});
it('coerces is_active to a boolean', async () => {
apiRequest.mockResolvedValue({});
await createApprovalRule(event, { ...base, is_active: 'true' });
const { body } = apiRequest.mock.calls[0][1];
expect(body.is_active).toBe(true);
expect(typeof body.is_active).toBe('boolean');
});
it('refuses a rule with no name before making a request', async () => {
await expect(createApprovalRule(event, { ...base, name: '' })).rejects.toThrow(
/needs a name/i
);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('updateApprovalRule', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('PUTs to the detail endpoint', async () => {
apiRequest.mockResolvedValue({});
await updateApprovalRule(event, 'r1', base);
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/approval-rules/r1/');
expect(opts.method).toBe('PUT');
});
it('sends only what it is given, with no trigger_event and no org/created_by', async () => {
apiRequest.mockResolvedValue({});
await updateApprovalRule(event, 'r1', {
...base,
org: 'ATTACKER-ORG',
created_by: 'ATTACKER'
});
const { body } = apiRequest.mock.calls[0][1];
expect(Object.keys(body).sort()).toEqual([
'approver_ids',
'approver_role',
'is_active',
'match_case_type',
'match_priority',
'match_team_id',
'name'
]);
expect(body.trigger_event).toBeUndefined();
expect(body.org).toBeUndefined();
expect(body.created_by).toBeUndefined();
});
it('sends exactly one key for a minimal { is_active: true } body (the turn-on path)', async () => {
apiRequest.mockResolvedValue({});
await updateApprovalRule(event, 'r1', { is_active: true });
const { body } = apiRequest.mock.calls[0][1];
expect(body).toEqual({ is_active: true });
});
it('sends exactly one key for a minimal { is_active: false } body (the turn-off path)', async () => {
apiRequest.mockResolvedValue({});
await updateApprovalRule(event, 'r1', { is_active: false });
const { body } = apiRequest.mock.calls[0][1];
expect(body).toEqual({ is_active: false });
});
it('throws when the id is missing', async () => {
await expect(updateApprovalRule(event, '', base)).rejects.toThrow(/which rule/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});
describe('deleteApprovalRule', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('DELETEs the detail endpoint', async () => {
apiRequest.mockResolvedValue(null);
await deleteApprovalRule(event, 'r1');
const [url, opts] = apiRequest.mock.calls[0];
expect(url).toBe('/cases/approval-rules/r1/');
expect(opts.method).toBe('DELETE');
});
it('reports a real delete when the response carries no body', async () => {
// `ApprovalRuleDetailView.delete` answers 204 on the hard-delete branch,
// and `apiRequest` turns a 204 into `null`.
apiRequest.mockResolvedValue(null);
await expect(deleteApprovalRule(event, 'r1')).resolves.toEqual({ turned_off: false });
});
it('reports a soft disable when the response says is_active is false', async () => {
// The other branch: `rule.requests.exists()` is true, so the view sets
// `is_active = False` and answers 200 with `{ id, is_active: false }`.
// Nothing about the status code distinguishes the two for a caller that
// only checks "did it throw", which is how the page came to promise a
// permanence the backend never delivered.
apiRequest.mockResolvedValue({ id: 'r1', is_active: false });
await expect(deleteApprovalRule(event, 'r1')).resolves.toEqual({ turned_off: true });
});
it('does not read a soft disable out of an unrelated 200 body', async () => {
apiRequest.mockResolvedValue({ id: 'r1' });
await expect(deleteApprovalRule(event, 'r1')).resolves.toEqual({ turned_off: false });
});
it('throws when the id is missing', async () => {
await expect(deleteApprovalRule(event, '')).rejects.toThrow(/which rule/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});

View file

@ -1,16 +1,19 @@
/**
* Weekly timesheet: the wiring behind /v2/timesheet.
*
* Server-only. One backend call, `GET /time-entries/timesheet/`, returns the
* caller's own week grouped into day buckets (every day present, empty or not)
* with week totals, the billable split, and a running-timer count. The page
* reads it verbatim as `data.week`.
* Server-only. `GET /time-entries/timesheet/` returns the caller's own week
* grouped into day buckets (every day present, empty or not) with week
* totals, the billable split, and a running-timer count. The page reads it
* verbatim as `data.week`.
*
* The v2 page is always "your timesheet", it has no profile switcher, so this
* layer never passes a `profile`, which also keeps it clear of the endpoint's
* admin-only "another profile" 403. `TimesheetView` already expands `case` and
* `invoice` to `{id, name}` / `{id, invoice_number}` and names the profile, so
* there is no reshaping here.
*
* `stopTimer` is the one write path here: `POST /time-entries/<id>/stop/`.
* Nothing else on this page is editable.
*/
import { apiRequest } from '$lib/api-helpers.js';
@ -45,3 +48,24 @@ export async function getTimesheet({ cookies }, { start, end } = {}) {
const week = await apiRequest(`/time-entries/timesheet/?${qs.toString()}`, {}, { cookies });
return { week };
}
/**
* Stop a running timer.
*
* The id guard is not defensive noise: an empty id would POST to
* `/time-entries//stop/`, which is a different path, and the failure would
* read as a routing bug rather than a missing argument.
*
* Ownership is the backend's call. The endpoint is org-scoped and checks the
* entry belongs to the caller, so this does not re-check it here and must not
* be read as having done so.
*
* @param {{ cookies: import('@sveltejs/kit').Cookies }} event
* @param {string} entryId
* @returns {Promise<any>} the stopped entry
*/
export async function stopTimer({ cookies }, entryId) {
if (!entryId) throw new Error('Which time entry? No entry id was given.');
return await apiRequest(`/time-entries/${entryId}/stop/`, { method: 'POST' }, { cookies });
}

View file

@ -0,0 +1,34 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const apiRequest = vi.fn();
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
const { stopTimer } = await import('$lib/server/v2/timesheet.js');
// Cast rather than shaping a full Cookies mock, matching leads.test.js and
// tags.test.js: stopTimer only ever calls `cookies.get`, and `apiRequest`
// itself is mocked above, so nothing here touches
// `getAll`/`set`/`delete`/`serialize`. Without the cast svelte-check flags
// this object against SvelteKit's full `Cookies` type on every call site
// below, which is noise for a shape the test deliberately keeps minimal.
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
describe('stopTimer', () => {
beforeEach(() => {
apiRequest.mockReset();
});
it('POSTs to the stop endpoint for the given entry', async () => {
apiRequest.mockResolvedValue({ id: 'e1', ended_at: '2026-08-04T10:00:00Z' });
const result = await stopTimer(event, 'e1');
const [endpoint, options] = apiRequest.mock.calls[0];
expect(endpoint).toBe('/time-entries/e1/stop/');
expect(options.method).toBe('POST');
expect(result.ended_at).toBeTruthy();
});
it('refuses a missing entry id rather than posting to a malformed path', async () => {
await expect(stopTimer(event, '')).rejects.toThrow(/entry/i);
expect(apiRequest).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,74 @@
<script>
/**
* A destructive action that takes two clicks.
*
* The first click swaps the button for a confirm and a cancel; the second
* submits. No `window.confirm`: a native dialog blocks the page, cannot be
* styled or explained, and is a dead end for anything driving the browser.
*
* `explain` is the one line of consequence shown while armed. Say what the
* action actually does. "Turn off" and "Delete permanently" are different
* promises and this codebase has both, sometimes behind the same verb on
* the backend.
*/
import { enhance } from '$app/forms';
/** @type {{
* action: string,
* label?: string,
* confirmLabel?: string,
* explain?: string,
* hidden?: Record<string, string>
* }} */
let {
action,
label = 'Delete',
confirmLabel = 'Confirm',
explain = '',
hidden = {}
} = $props();
let armed = $state(false);
let busy = $state(false);
</script>
{#if armed}
<form
class="v2-confirm"
method="POST"
{action}
use:enhance={() => {
busy = true;
return async (/** @type {any} */ { update }) => {
await update();
busy = false;
// Disarm either way. On success the row is gone; on failure the page
// error is what the user needs to read, not a still-armed button.
armed = false;
};
}}
>
{#each Object.entries(hidden) as [name, value] (name)}
<input type="hidden" {name} {value} />
{/each}
{#if explain}
<span class="v2-sub" style="font-size:11.5px">{explain}</span>
{/if}
<button class="v2-btn v2-btn-sm" type="submit" disabled={busy}>{confirmLabel}</button>
<button class="v2-btn v2-btn-sm" type="button" disabled={busy} onclick={() => (armed = false)}>
Cancel
</button>
</form>
{:else}
<button class="v2-btn v2-btn-sm" type="button" onclick={() => (armed = true)}>{label}</button>
{/if}
<style>
.v2-confirm {
display: inline-flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
justify-content: flex-end;
}
</style>

View file

@ -0,0 +1,112 @@
<script>
/**
* The chrome around a settings form.
*
* Every settings page in v2 is a list with an "add" or "edit" control, and
* every one of those forms needs the same four things: somewhere to put the
* error, a submit that cannot be double-fired, a cancel, and the rule that
* a failed submit leaves the form open so the error stays on screen. That
* rule is the reason this exists as a component rather than as markup
* copied eight times: getting it wrong closes the panel and takes the
* message with it, and it is invisible until someone submits something
* invalid.
*
* The fields are the caller's, passed as a snippet. This component holds no
* opinion about what is being edited and no per-resource state.
*
* Visibility is the caller's too: render this inside an `{#if}`. A panel
* that owned its own `open` flag would need the caller to reach in and set
* it to switch between "add" and "edit this row", which is worse than an
* `{#if}`.
*/
import { enhance } from '$app/forms';
/** @type {{
* title: string,
* action: string,
* error?: string | null,
* submitLabel?: string,
* oncancel: () => void,
* ondone?: () => void,
* fields: import('svelte').Snippet
* }} */
let {
title,
action,
error = null,
submitLabel = 'Save',
oncancel,
ondone,
fields
} = $props();
// Disables submit while a request is in flight so a double-click cannot
// fire two writes.
let busy = $state(false);
</script>
<div class="v2-card v2-sfp">
<form
method="POST"
{action}
use:enhance={() => {
busy = true;
return async (/** @type {any} */ { result, update }) => {
await update();
busy = false;
// Close only on success. A rejected submit has to leave the panel
// open, or the error rendered above the fields is unmounted the
// instant the action returns and nobody ever reads it.
if (result.type === 'success') ondone?.();
};
}}
>
<div class="v2-sfp-head">{title}</div>
{#if error}
<p class="v2-error" style="margin:0 0 12px">{error}</p>
{/if}
<div class="v2-sfp-fields">
{@render fields()}
</div>
<div class="v2-sfp-foot">
<button class="v2-btn v2-btn-primary" type="submit" disabled={busy}>{submitLabel}</button>
<button class="v2-btn" type="button" disabled={busy} onclick={oncancel}>Cancel</button>
</div>
</form>
</div>
<style>
.v2-sfp {
padding: 16px 17px;
margin-bottom: 18px;
}
.v2-sfp-head {
font-weight: 600;
font-size: 13px;
margin-bottom: 13px;
}
/* Two columns on a wide screen, one on a narrow one. A settings form is
mostly short fields (a number, a select, a checkbox) and a single column
of them wastes the width these pages have. A field that needs the full
row opts in with `grid-column: 1 / -1` via the `v2-sfp-wide` class. */
.v2-sfp-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 13px 18px;
align-items: start;
}
.v2-sfp-foot {
display: flex;
gap: 8px;
margin-top: 16px;
}
@media (max-width: 768px) {
.v2-sfp-fields {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -312,6 +312,16 @@ export const ROUTING_STRATEGY_LABEL = {
by_team: 'Anyone on'
};
/** Standalone names for the strategy select. `ROUTING_STRATEGY_LABEL` above
* is a sentence fragment that runs into the target names on the rule card,
* so it cannot double as an option label. */
export const ROUTING_STRATEGY_NAME = {
direct: 'Direct',
round_robin: 'Round robin',
least_busy: 'Least busy',
by_team: 'By team'
};
/** Fields a routing condition can test, in the words the rest of the app uses. */
export const CONDITION_FIELD_LABEL = {
priority: 'Priority',
@ -336,6 +346,12 @@ export const ESCALATION_ACTION_LABEL = {
notify_and_reassign: 'Notify and reassign to'
};
/** cases.EscalationPolicy priorities, worst first. This is the display order
* the escalation page sorts by, not the model's `ordering = ("priority",)`,
* which sorts the CharField alphabetically and puts Low between High and
* Normal. Imported by both the page and `lib/server/v2/escalation.js`. */
export const ESCALATION_PRIORITIES = ['Urgent', 'High', 'Normal', 'Low'];
/** cases.InboundMailbox.PROVIDER_CHOICES, only SES ships today. */
export const MAILBOX_PROVIDER_LABEL = {
ses: 'Amazon SES',
@ -356,6 +372,16 @@ export const FIELD_TYPE_LABEL = {
checkbox: 'Checkbox'
};
/** The statuses a reopened ticket may come back as.
*
* `ReopenPolicySerializer.NON_TERMINAL_STATUSES` in
* `backend/cases/serializer.py` is the authority and rejects anything else.
* Reopening into a terminal status would close the ticket again on arrival,
* so the model's own choice list, which also carries Closed, Rejected and
* Duplicate, is not the list to offer.
*/
export const REOPEN_TO_STATUSES = ['New', 'Assigned', 'Pending'];
/** Plural forms for the target model, for reading in a heading. */
export const TARGET_MODEL_LABEL = {
Account: 'Accounts',

View file

@ -0,0 +1,66 @@
/**
* Keeping a stored target that the picker list cannot offer.
*
* Every people picker on the settings pages is built from
* `getOrgPeopleAndTeams`, and `GetTeamsAndUsersView` filters profiles to
* `is_active=True, org=request.profile.org`. None of the four `cases` settings
* resources filter `is_active` on the write side, so a profile that was a
* legal target when it was chosen stays the stored target after it is
* deactivated. The list the form renders from can no longer represent it.
*
* A `<select>` whose bound value matches no option reports
* `selectedIndex === -1`, and a select with no selected option contributes NO
* entry to `FormData`. The action then reads that absence as `''` and the
* body builder turns `''` into `null`. So an admin who opens an edit form to
* change something unrelated, and saves, silently clears a target they never
* touched. On an escalation policy that means Urgent breaches stop escalating
* to anybody, with no message anywhere.
*
* The fix is to give the select something to match: render the stored value as
* its own option, labelled so the admin can see why it is there. That is what
* these two helpers find. They live in `$lib/v2/` rather than `$lib/server/v2/`
* because a `.svelte` file cannot import from `$lib/server/`, and here they can
* still be unit tested, unlike the markup that consumes them.
*
* "Not in the list" is the test, not "is_active is false": the pages that need
* this do not receive `is_active` on a stored target, and absence from a list
* that only ever contains active in-org profiles means the same thing. The one
* case where it means something else is a failed picker fetch, which
* `getOrgPeopleAndTeams` reports as empty lists; the form is unusable then
* anyway, and keeping the stored value is still the right outcome.
*/
/**
* The stored targets the options list cannot offer.
*
* @param {{ id: string }[]} options what the picker can currently show
* @param {any[]} stored what the record actually points at
* @returns {any[]} the entries of `stored` with no matching option, in order
*/
export function missingOptions(options, stored) {
const known = new Set((options ?? []).map((o) => o?.id).filter(Boolean));
return (stored ?? []).filter((s) => s && s.id && !known.has(s.id));
}
/**
* The single stored target an options list cannot offer, or null.
*
* @param {{ id: string }[]} options
* @param {any} stored a single target object, or null when nothing is set
* @returns {any} the target when it is unrepresentable, null otherwise
*/
export function missingOption(options, stored) {
return missingOptions(options, stored ? [stored] : [])[0] ?? null;
}
/**
* How such an option reads. Not a bare name: the point is that the admin can
* tell the difference between a person they can pick and a person who is only
* there because the record already points at them.
*
* @param {string | null | undefined} name
* @returns {string}
*/
export function inactiveOptionLabel(name) {
return `${name || 'Unnamed'} (no longer active)`;
}

View file

@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { missingOption, missingOptions, inactiveOptionLabel } from './pickers.js';
const people = [
{ id: 'p1', name: 'Ada Lovelace' },
{ id: 'p2', name: 'Grace Hopper' }
];
describe('missingOptions', () => {
it('returns the stored entries the options list cannot offer', () => {
const stored = [{ id: 'p1' }, { id: 'gone', email: 'left@x.io' }];
expect(missingOptions(people, stored)).toEqual([{ id: 'gone', email: 'left@x.io' }]);
});
it('returns nothing when every stored entry is in the list', () => {
expect(missingOptions(people, [{ id: 'p1' }, { id: 'p2' }])).toEqual([]);
});
it('keeps the stored order', () => {
const stored = [{ id: 'a' }, { id: 'p1' }, { id: 'b' }];
expect(missingOptions(people, stored).map((s) => s.id)).toEqual(['a', 'b']);
});
it('treats an empty options list as offering nothing', () => {
// What a failed `getOrgPeopleAndTeams` fetch looks like. Keeping the
// stored value is still the right outcome: the form cannot offer an
// alternative, so discarding it would be pure loss.
expect(missingOptions([], [{ id: 'p1' }])).toEqual([{ id: 'p1' }]);
});
it('survives null and undefined on either side', () => {
expect(missingOptions(null, null)).toEqual([]);
expect(missingOptions(undefined, undefined)).toEqual([]);
expect(missingOptions(people, undefined)).toEqual([]);
});
it('ignores stored entries with no id', () => {
expect(missingOptions(people, [null, {}, { id: '' }])).toEqual([]);
});
});
describe('missingOption', () => {
it('returns the stored target when the list cannot offer it', () => {
expect(missingOption(people, { id: 'gone', name: 'Departed' })).toEqual({
id: 'gone',
name: 'Departed'
});
});
it('returns null when the list can offer it', () => {
expect(missingOption(people, { id: 'p1', name: 'Ada Lovelace' })).toBeNull();
});
it('returns null when nothing is stored', () => {
expect(missingOption(people, null)).toBeNull();
expect(missingOption(people, undefined)).toBeNull();
});
});
describe('inactiveOptionLabel', () => {
it('says the target is no longer active', () => {
expect(inactiveOptionLabel('Ada Lovelace')).toBe('Ada Lovelace (no longer active)');
});
it('falls back to Unnamed rather than rendering null', () => {
// Both `shapeTarget` and `shapeAssignee` resolve a missing
// `user_details.name` to null, so a bare interpolation would read
// "null (no longer active)".
expect(inactiveOptionLabel(null)).toBe('Unnamed (no longer active)');
expect(inactiveOptionLabel('')).toBe('Unnamed (no longer active)');
});
});

View file

@ -1002,6 +1002,17 @@ a.v2-setting:hover {
.v2-field {
margin-bottom: 15px;
}
/* `.v2-field` carries a bottom margin for the single-column forms it was
written for (`/leads/new`). Inside the panel the grid's own gap does that
job, and both together double the spacing. */
.v2-sfp-fields .v2-field {
margin-bottom: 0;
}
/* Opt a field out of the panel's two-column grid when it needs the whole
row: a textarea, a checkbox with a sentence next to it, an options list. */
.v2-sfp-wide {
grid-column: 1 / -1;
}
.v2-field > label {
display: block;
font-size: 12.5px;

View file

@ -42,8 +42,11 @@
<span class="v2-num">{totals.unworked_over_a_week}</span> unworked for more than a week
{/snippet}
{#snippet actions()}
<!-- Import stays unwired: /api/leads/import/ does not exist yet. Contacts
and cases both have import/preview/ and import/commit/; leads does not.
Tracked in the phase 2 plan. -->
<button class="v2-btn"><Upload />Import</button>
<button class="v2-btn v2-btn-primary"><Plus />New {singular}</button>
<a class="v2-btn v2-btn-primary" href="/leads/new"><Plus />New {singular}</a>
{/snippet}
</PageHeader>
@ -57,7 +60,7 @@
>
{#snippet icon()}<Target size={21} />{/snippet}
{#snippet actions()}
<button class="v2-btn v2-btn-primary">New {singular}</button>
<a class="v2-btn v2-btn-primary" href="/leads/new">New {singular}</a>
<button class="v2-btn">Import</button>
{/snippet}
</EmptyState>

View file

@ -0,0 +1,32 @@
import { fail, redirect } from '@sveltejs/kit';
import { EDITABLE_FIELDS, createLead, getLeadFormOptions } from '$lib/server/v2/leads.js';
import { readableError } from '$lib/server/v2/form-errors.js';
/** @type {import('./$types').PageServerLoad} */
export async function load(event) {
return await getLeadFormOptions(event);
}
/** @type {import('./$types').Actions} */
export const actions = {
async create(event) {
const form = await event.request.formData();
/** @type {Record<string, any>} */
const values = {};
for (const field of [...EDITABLE_FIELDS, 'assigned_to']) {
if (form.has(field)) values[field] = form.get(field)?.toString().trim() ?? '';
}
/** @type {any} */
let created;
try {
created = await createLead(event, values);
} catch (/** @type {any} */ err) {
// Values go back so a rejected form is not a blank form.
return fail(400, { values, error: readableError(err, 'Could not create the lead.') });
}
redirect(303, created?.id ? `/leads/${created.id}` : '/leads');
}
};

View file

@ -0,0 +1,324 @@
<script>
/**
* Adding a lead.
*
* A brand-new org cannot add its first lead through the web UI without this
* page. `/leads` has always rendered a "New lead" button; nothing was ever
* behind it. See CLAUDE.md, "API Validation & Authorization": the checks
* below are a courtesy that saves a round trip, not the rule. The
* serializer runs again on everything that arrives, and a curl request or a
* stale build reaches it without ever loading this file.
*
* Nothing here binds to `org`, `created_by`, or an assigned-by field. Those
* are server-derived; `createLead` never reads them off the form, and the
* action below only ever forwards keys named in `EDITABLE_FIELDS`.
*/
import { tick, untrack } from 'svelte';
import { enhance } from '$app/forms';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import {
LEAD_STATUSES,
LEAD_STATUS_LABEL,
LEAD_SOURCES,
LEAD_SOURCE_LABEL,
INDUSTRIES,
industryLabel
} from '$lib/v2/enums.js';
import { money } from '$lib/v2/format.js';
import { TriangleAlert } from '@lucide/svelte';
/** @type {{ data: any, form: any }} */
let { data, form: result } = $props();
// Seeded from the load once, on purpose: a half-typed form must not be
// wiped if the page data revalidates underneath it. untrack() says that
// out loud rather than leaving it as an accident.
let form = $state(
untrack(() => ({
first_name: '',
last_name: '',
company_name: '',
email: '',
assigned_to: data.defaults.assigned_to,
job_title: '',
phone: '',
website: '',
status: data.defaults.status,
source: '',
industry: '',
opportunity_amount: '',
description: '',
// A rejected submit comes back with what was typed. Retyping a dozen
// fields because one collided is how people learn to distrust a create
// page.
...(untrack(() => result?.values) ?? {})
}))
);
let touched = $state(/** @type {Record<string, boolean>} */ ({}));
let submitted = $state(false);
// Disables the submit button while the create action is in flight, so a
// double-click cannot fire two creates.
let busy = $state(false);
let errors = $derived.by(() => {
/** @type {Record<string, string>} */
const e = {};
// Mirrors the edit form's own rule: a lead needs a name to be findable,
// but not both halves of one. Neither half is required by the API
// itself (Lead.first_name/last_name are both nullable), so this is a UX
// floor, not the boundary; an empty lead would otherwise save silently
// and be unfindable in the list a moment later.
if (!form.first_name.trim() && !form.last_name.trim())
e.last_name = 'A lead needs a name to be findable. First or last will do.';
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
e.email = 'That does not look like an email address.';
// The exact regex from `flexible_phone_validator`. Extensions like "x123"
// are rejected by the model, so they are caught here rather than as an
// opaque whole-form refusal after the save.
if (form.phone && !/^[\d\s\-()+.]{7,25}$/.test(form.phone))
e.phone = '7 to 25 characters: digits, spaces, brackets, dots, dashes. No extensions.';
// `createLead` runs `Number(amount)` and the body is JSON-encoded, and
// `JSON.stringify` turns `NaN` into `null`. Without this check, typing
// "abc" would not error, it would silently save as no value at all.
if (form.opportunity_amount !== '') {
const n = Number(form.opportunity_amount);
if (!Number.isFinite(n)) e.opportunity_amount = 'Estimated value has to be a number.';
else if (n < 0) e.opportunity_amount = 'Estimated value cannot be negative.';
}
return e;
});
let valid = $derived(Object.keys(errors).length === 0);
const show = (/** @type {string} */ field) => (touched[field] || submitted) && errors[field];
/**
* On success the action redirects to the new lead, so there is no success
* state to render here.
*
* @type {import('./$types').SubmitFunction}
*/
const check = async ({ cancel }) => {
submitted = true;
if (!valid) {
// Send focus to the first field that needs work rather than only
// colouring it. await tick() matters: on the first submit the
// aria-invalid attributes do not exist until Svelte flushes, so
// querying now finds nothing.
cancel();
await tick();
/** @type {HTMLElement | null} */
const first = document.querySelector('[aria-invalid="true"]');
first?.focus();
return;
}
busy = true;
return async (/** @type {any} */ { update }) => {
await update();
busy = false;
};
};
</script>
<PageHeader title="New lead" center>
{#snippet crumb()}
<a href="/leads">Leads</a> ›
{/snippet}
{#snippet sub()}
A name and a company is enough to start. The rest can wait.
{/snippet}
</PageHeader>
<div class="v2-scroll v2-pad" style="padding-top:18px">
<form class="v2-form" method="POST" action="?/create" use:enhance={check} novalidate>
{#if result?.error}
<div
class="v2-next"
style="background:color-mix(in srgb, var(--v2-rust) 9%, transparent);border-color:color-mix(in srgb, var(--v2-rust) 28%, transparent);margin-bottom:18px"
role="alert"
>
<TriangleAlert size={17} style="color:var(--v2-rust);flex:none" />
<div class="v2-next-body">
<div style="font-weight:600">The server refused this lead</div>
<div class="v2-sub" style="margin-top:2px">{result.error}</div>
</div>
</div>
{/if}
{#if submitted && !valid}
<div
class="v2-next"
style="background:color-mix(in srgb, var(--v2-rust) 9%, transparent);border-color:color-mix(in srgb, var(--v2-rust) 28%, transparent);margin-bottom:18px"
role="alert"
>
<TriangleAlert size={17} style="color:var(--v2-rust);flex:none" />
<div class="v2-next-body">
<div style="font-weight:600">
{Object.keys(errors).length} field{Object.keys(errors).length === 1 ? '' : 's'} still need{Object.keys(
errors
).length === 1
? 's'
: ''} you
</div>
<div class="v2-sub" style="margin-top:2px">Nothing has been saved.</div>
</div>
</div>
{/if}
<div class="v2-field">
<label for="f-first">First name</label>
<input
id="f-first"
name="first_name"
class="v2-input"
bind:value={form.first_name}
onblur={() => (touched.first_name = true)}
/>
</div>
<div class="v2-field">
<label for="f-last">Last name</label>
<input
id="f-last"
name="last_name"
class="v2-input"
bind:value={form.last_name}
onblur={() => (touched.last_name = true)}
aria-invalid={show('last_name') ? 'true' : undefined}
aria-describedby={show('last_name') ? 'e-last' : undefined}
/>
{#if show('last_name')}<p class="v2-error" id="e-last">{errors.last_name}</p>{/if}
</div>
<div class="v2-field">
<label for="f-company">Company</label>
<input id="f-company" name="company_name" class="v2-input" bind:value={form.company_name} />
</div>
<div class="v2-field">
<label for="f-email">Email</label>
<input
id="f-email"
name="email"
class="v2-input"
type="email"
bind:value={form.email}
onblur={() => (touched.email = true)}
aria-invalid={show('email') ? 'true' : undefined}
aria-describedby={show('email') ? 'e-email' : undefined}
/>
{#if show('email')}<p class="v2-error" id="e-email">{errors.email}</p>{/if}
</div>
<div class="v2-field">
<label for="f-owner">Owner</label>
<select id="f-owner" name="assigned_to" class="v2-input" bind:value={form.assigned_to}>
<option value="">Unassigned</option>
{#each data.owners as o (o.id)}
<option value={o.id}>{o.name}</option>
{/each}
</select>
</div>
<div class="v2-field">
<label for="f-jobtitle">Job title</label>
<input id="f-jobtitle" name="job_title" class="v2-input" bind:value={form.job_title} />
</div>
<div class="v2-field">
<label for="f-phone">Phone</label>
<input
id="f-phone"
name="phone"
class="v2-input"
bind:value={form.phone}
onblur={() => (touched.phone = true)}
aria-invalid={show('phone') ? 'true' : undefined}
aria-describedby={show('phone') ? 'e-phone' : undefined}
/>
{#if show('phone')}<p class="v2-error" id="e-phone">{errors.phone}</p>{/if}
</div>
<div class="v2-field">
<label for="f-website">Website</label>
<input id="f-website" name="website" class="v2-input" bind:value={form.website} />
</div>
<div class="v2-field">
<label for="f-status">Status</label>
<select id="f-status" name="status" class="v2-input" bind:value={form.status}>
{#each LEAD_STATUSES.filter((s) => s !== 'converted') as s (s)}
<option value={s}>{LEAD_STATUS_LABEL[s]}</option>
{/each}
</select>
<p class="v2-hint">
Converting is a significant, largely irreversible step: it creates an Account, a Contact and
an Opportunity that nothing undoes if the status changes back, and it requires an email
address that nothing else here does. Set the status here to something else, and convert once
the lead is real.
</p>
</div>
<div class="v2-field">
<label for="f-source">Source</label>
<select id="f-source" name="source" class="v2-input" bind:value={form.source}>
<option value="">Not specified</option>
{#each LEAD_SOURCES as s (s)}
<option value={s}>{LEAD_SOURCE_LABEL[s]}</option>
{/each}
</select>
</div>
<div class="v2-field">
<label for="f-industry">Industry</label>
<select id="f-industry" name="industry" class="v2-input" bind:value={form.industry}>
<option value="">Not specified</option>
{#each INDUSTRIES as ind (ind)}
<option value={ind}>{industryLabel(ind)}</option>
{/each}
</select>
</div>
<div class="v2-field">
<label for="f-amount">Estimated value</label>
<input
id="f-amount"
name="opportunity_amount"
class="v2-input v2-num"
type="number"
step="any"
bind:value={form.opportunity_amount}
onblur={() => (touched.opportunity_amount = true)}
aria-invalid={show('opportunity_amount') ? 'true' : undefined}
aria-describedby={show('opportunity_amount') ? 'e-amount' : 'h-amount'}
/>
{#if show('opportunity_amount')}
<p class="v2-error" id="e-amount">{errors.opportunity_amount}</p>
{:else}
<p class="v2-hint" id="h-amount">
{Number(form.opportunity_amount) > 0
? money(Number(form.opportunity_amount))
: 'What the deal would be worth if it lands.'}
</p>
{/if}
</div>
<div class="v2-field">
<label for="f-notes">Notes</label>
<textarea
id="f-notes"
name="description"
class="v2-input"
rows="4"
bind:value={form.description}></textarea>
</div>
<div class="actions">
<button class="v2-btn v2-btn-primary" type="submit" disabled={busy}>Create lead</button>
<a class="v2-btn" href="/leads">Cancel</a>
</div>
</form>
</div>
<style>
.actions {
display: flex;
align-items: center;
gap: 9px;
margin-top: 22px;
padding-bottom: 40px;
}
</style>

View file

@ -1,6 +1,121 @@
import { getBusinessHours } from '$lib/server/v2/business-hours.js';
import { fail } from '@sveltejs/kit';
import {
getBusinessHours,
updateBusinessHours,
addHoliday as addHolidayWrite,
removeHoliday as removeHolidayWrite
} from '$lib/server/v2/business-hours.js';
import { readableError } from '$lib/server/v2/form-errors.js';
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
return getBusinessHours({ cookies });
}
/** Monday-first weekday label ↔ the form field prefix each row posts under.
* Kept in one place so the seven names are typed once here, not seven
* times across the read of a form submission. */
const WEEKDAYS = [
['Monday', 'monday'],
['Tuesday', 'tuesday'],
['Wednesday', 'wednesday'],
['Thursday', 'thursday'],
['Friday', 'friday'],
['Saturday', 'saturday'],
['Sunday', 'sunday']
];
/**
* Read the week back out of the form: one `open` and one `close` input per
* day plus a `closed` checkbox, all named by weekday prefix. A checked
* "closed" box wins outright and reports both fields null for that day,
* regardless of whatever the (disabled, and so never actually submitted by
* the browser) time inputs hold.
*
* @param {FormData} form
*/
function readDays(form) {
return WEEKDAYS.map(([label, key]) => {
const closed = form.get(`${key}_closed`) === 'true';
return {
day: label,
open: closed ? null : form.get(`${key}_open`)?.toString() || null,
close: closed ? null : form.get(`${key}_close`)?.toString() || null
};
});
}
/** The one 403 body all three admin-only writes share, turned into a sentence. */
const FORBIDDEN = 'Only admins can update business hours.';
/**
* All three actions below are admin-only, enforced server-side by
* `BusinessCalendarView.put` / `BusinessHolidayListView.post` /
* `BusinessHolidayDetailView.delete`, each re-deriving admin status from
* `request.profile`. `data.can_edit` in the page only hides the controls;
* it is a display hint, never the authorization decision.
*
* @type {import('./$types').Actions}
*/
export const actions = {
async updateHours(event) {
const form = await event.request.formData();
const days = readDays(form);
const meta = {
name: form.get('name')?.toString().trim() ?? '',
timezone: form.get('timezone')?.toString().trim() ?? ''
};
try {
// The calendar id never travels through the form: there is exactly
// one calendar per org, and this read is org-scoped server-side, so
// resolving it here rather than trusting a hidden field means there
// is nothing for a hand-crafted POST to redirect at another org's row.
const { calendar } = await getBusinessHours(event);
await updateBusinessHours(event, calendar.id, days, meta);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { updateHours: { error: FORBIDDEN } });
}
return fail(400, {
updateHours: { error: readableError(err, 'Could not save business hours.') }
});
}
return { hoursUpdated: true };
},
async addHoliday(event) {
const form = await event.request.formData();
const date = form.get('date')?.toString() ?? '';
const name = form.get('name')?.toString().trim() ?? '';
try {
const { calendar } = await getBusinessHours(event);
await addHolidayWrite(event, calendar.id, { date, name });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { addHoliday: { error: FORBIDDEN } });
}
return fail(400, { addHoliday: { error: readableError(err, 'Could not add the holiday.') } });
}
// Adding a date already on the calendar is idempotent (200, the existing
// row, no duplicate): not an error, so this path is the same "success"
// whether the row was created or already existed.
return { holidayAdded: true };
},
async removeHoliday(event) {
const form = await event.request.formData();
const holidayId = form.get('holiday_id')?.toString() ?? '';
try {
const { calendar } = await getBusinessHours(event);
await removeHolidayWrite(event, calendar.id, holidayId);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { removeHoliday: { error: FORBIDDEN } });
}
return fail(400, {
removeHoliday: { error: readableError(err, 'Could not remove the holiday.') }
});
}
return { holidayRemoved: true };
}
};

View file

@ -10,14 +10,22 @@
*
* Closed days and holidays are shown, not omitted. A blank row for Saturday
* reads as missing data; "Closed" reads as a decision.
*
* "Edit hours" and "Add" open the two panels below. `data.can_edit` only
* decides whether those controls are offered: it is a display hint decoded
* from the JWT, never the authorization decision. The backend re-derives
* admin status from `request.profile` on every write and is what actually
* refuses a non-admin.
*/
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import SettingsCrumb from '$lib/v2/components/SettingsCrumb.svelte';
import SettingsFormPanel from '$lib/v2/components/SettingsFormPanel.svelte';
import ConfirmAction from '$lib/v2/components/ConfirmAction.svelte';
import { shortDate, relativeDays } from '$lib/v2/format.js';
import { Plus, Clock } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
let calendar = $derived(data.calendar);
@ -31,6 +39,34 @@
let weekly = $derived(calendar.days.reduce((a, d) => a + hours(d), 0));
const todayName = new Intl.DateTimeFormat('en-GB', { weekday: 'long' }).format(new Date());
// `null` when the panel is closed. One panel for the week, so only one
// edit can be in flight at a time.
let editingHours = $state(false);
// The week being edited, seeded from `calendar.days` when the panel opens.
// A day's key is its own name lower-cased ("Monday" → "monday"), the same
// prefix the model's fourteen flat fields use, so no separate label map is
// needed here. `closed` drives both the checkbox and whether the two time
// inputs are disabled; a closed day still carries a sensible default time
// so re-opening it doesn't hand back a blank field.
let hourRows = $state(
/** @type {{ day: string, key: string, open: string, close: string, closed: boolean }[]} */ ([])
);
function openHoursEdit() {
hourRows = calendar.days.map((d) => ({
day: d.day,
key: d.day.toLowerCase(),
open: d.open ?? '09:00',
close: d.close ?? '17:00',
closed: !d.open
}));
editingHours = true;
}
// `null` when the panel is closed.
let addingHoliday = $state(false);
</script>
<PageHeader title="Business hours">
@ -40,7 +76,9 @@
<span class="v2-num">{weekly}</span> hours a week
{/snippet}
{#snippet actions()}
<button class="v2-btn v2-btn-primary">Edit hours</button>
{#if data.can_edit && !editingHours}
<button class="v2-btn v2-btn-primary" onclick={openHoursEdit}>Edit hours</button>
{/if}
{/snippet}
</PageHeader>
@ -49,6 +87,84 @@
<div class="v2-split">
<div>
<div class="v2-label" style="margin-bottom:10px">Open hours</div>
{#if editingHours}
<SettingsFormPanel
title="Edit business hours"
action="?/updateHours"
error={form?.updateHours?.error}
submitLabel="Save hours"
oncancel={() => (editingHours = false)}
ondone={() => (editingHours = false)}
>
{#snippet fields()}
<div class="v2-field">
<label for="bh-name">Name</label>
<input
id="bh-name"
class="v2-input"
name="name"
maxlength="100"
required
value={calendar.name}
/>
</div>
<div class="v2-field">
<label for="bh-timezone">Timezone</label>
<input
id="bh-timezone"
class="v2-input"
name="timezone"
maxlength="64"
required
value={calendar.timezone}
placeholder="America/New_York"
/>
<p class="v2-hint">IANA timezone name.</p>
</div>
<div class="v2-field v2-sfp-wide">
<label for="bh-day-0-open">Week</label>
{#each hourRows as row, i (row.key)}
<div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
<span style="width:84px;font-size:13px;flex:none">{row.day}</span>
<label
style="display:flex;gap:6px;align-items:center;font-size:12px;font-weight:400;flex:none"
>
<input
type="checkbox"
name="{row.key}_closed"
value="true"
bind:checked={row.closed}
/>
Closed
</label>
<input
id={i === 0 ? 'bh-day-0-open' : undefined}
class="v2-input"
type="time"
name="{row.key}_open"
bind:value={row.open}
disabled={row.closed}
style="width:auto"
/>
<span class="v2-sub">to</span>
<input
class="v2-input"
type="time"
name="{row.key}_close"
bind:value={row.close}
disabled={row.closed}
style="width:auto"
/>
</div>
{/each}
</div>
{/snippet}
</SettingsFormPanel>
{/if}
<div class="v2-card" style="overflow:hidden">
{#each calendar.days as d (d.day)}
<div class="v2-setting" style={d.day === todayName ? 'background:var(--v2-hover)' : ''}>
@ -79,8 +195,50 @@
<div>
<div style="display:flex;align-items:baseline;margin-bottom:10px">
<div class="v2-label">Holidays</div>
<button class="v2-btn v2-btn-sm" style="margin-left:auto"><Plus size={12} />Add</button>
{#if data.can_edit && !addingHoliday}
<button
class="v2-btn v2-btn-sm"
style="margin-left:auto"
onclick={() => (addingHoliday = true)}
>
<Plus size={12} />Add
</button>
{/if}
</div>
{#if addingHoliday}
<SettingsFormPanel
title="Add holiday"
action="?/addHoliday"
error={form?.addHoliday?.error}
submitLabel="Add holiday"
oncancel={() => (addingHoliday = false)}
ondone={() => (addingHoliday = false)}
>
{#snippet fields()}
<div class="v2-field">
<label for="bh-holiday-date">Date</label>
<input id="bh-holiday-date" class="v2-input" type="date" name="date" required />
</div>
<div class="v2-field">
<label for="bh-holiday-name">Name</label>
<input
id="bh-holiday-name"
class="v2-input"
name="name"
maxlength="100"
required
placeholder="Christmas"
/>
</div>
{/snippet}
</SettingsFormPanel>
{/if}
{#if form?.removeHoliday?.error}
<p class="v2-error" style="margin-bottom:12px">{form.removeHoliday.error}</p>
{/if}
<div class="v2-card" style="overflow:hidden">
{#each calendar.holidays as h (h.id)}
<div class="v2-setting">
@ -89,6 +247,15 @@
<span class="v2-sub" style="font-size:11.5px">{relativeDays(h.date)}</span>
</div>
<span class="v2-num" style="font-size:12.5px">{shortDate(h.date)}</span>
{#if data.can_edit}
<ConfirmAction
action="?/removeHoliday"
label="Remove"
confirmLabel="Remove"
explain="Deletes it. The day counts as working time again."
hidden={{ holiday_id: h.id }}
/>
{/if}
</div>
{:else}
<p class="v2-sub" style="padding:14px 16px;font-size:12.5px;margin:0">
@ -105,9 +272,16 @@
<div style="font-weight:600;font-size:13px">What this changes</div>
<p class="v2-sub" style="font-size:12px;margin:4px 0 0">
Response and resolution targets count only the time inside these hours. A ticket
opened at 17:20 on Friday starts its clock at
<span class="v2-num">{calendar.days[0].open}</span> on Monday, so the weekend does not spend
a four-hour target.
opened at 17:20 on Friday
{#if calendar.days[0].open}
starts its clock at <span class="v2-num">{calendar.days[0].open}</span> on Monday,
{:else}
<!-- Monday can be marked closed from this page now, so the
fixed "Monday morning" framing can no longer assume an
open time exists to quote. -->
starts its clock whenever the week next opens,
{/if}
so the weekend does not spend a four-hour target.
</p>
<p class="v2-sub" style="font-size:12px;margin:8px 0 0">
<a href="/tickets/analytics" style="color:inherit">Service analytics</a> is measured on

View file

@ -1,6 +1,119 @@
import { getCustomFields } from '$lib/server/v2/custom-fields.js';
import { fail } from '@sveltejs/kit';
import {
getCustomFields,
createCustomField,
updateCustomField,
deactivateCustomField
} from '$lib/server/v2/custom-fields.js';
import { readableError } from '$lib/server/v2/form-errors.js';
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
return getCustomFields({ cookies });
}
/**
* Read the repeated option rows back into `{ value, label }` pairs.
*
* The form renders one hidden `option_value` and one text `option_label` per
* row, so `getAll` returns two parallel arrays. A blank value marks a row the
* admin just added; `createCustomField` slugifies a value for it. A non-blank
* one is an existing option whose value is referenced by every record already
* holding it, and it travels back unchanged.
*
* @param {FormData} form
*/
function readOptions(form) {
const values = form.getAll('option_value').map((v) => v.toString());
const labels = form.getAll('option_label').map((v) => v.toString());
return labels.map((label, i) => ({ value: values[i] ?? '', label }));
}
/** @param {FormData} form */
function readValues(form) {
return {
target_model: form.get('target_model')?.toString() ?? '',
key: form.get('key')?.toString().trim() ?? '',
label: form.get('label')?.toString().trim() ?? '',
field_type: form.get('field_type')?.toString() ?? '',
is_required: form.get('is_required') === 'true',
is_filterable: form.get('is_filterable') === 'true',
display_order: form.get('display_order')?.toString() ?? '0',
options: readOptions(form)
};
}
/** @type {import('./$types').Actions} */
export const actions = {
// Admin-only server-side (`CustomFieldDefinitionListCreateView.post` calls
// `_is_admin` first). `can_edit` only hides the control.
async create(event) {
const form = await event.request.formData();
const values = readValues(form);
try {
await createCustomField(event, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { create: { error: 'Only an admin can add custom fields.' } });
}
return fail(400, { create: { error: readableError(err, 'Could not add the field.') } });
}
return { created: true };
},
async update(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
const values = readValues(form);
try {
await updateCustomField(event, id, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { update: { error: 'Only an admin can change custom fields.' } });
}
return fail(400, { update: { error: readableError(err, 'Could not save the field.') } });
}
return { updated: true };
},
async deactivate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await deactivateCustomField(event, id);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { deactivate: { error: 'Only an admin can turn custom fields off.' } });
}
return fail(400, {
deactivate: { error: readableError(err, 'Could not turn the field off.') }
});
}
return { deactivated: true };
},
// A field's own action, not a reuse of `update`. `readValues` builds a full
// field payload from the form, and this control only ever submits an id: if
// it posted through `update`, the missing `label` would read back as `''`
// (FormData has no key for it, `readValues` falls back to an empty string,
// not `undefined`) and `buildBody` would forward that empty string, since
// its allow-list only skips a field that is `undefined`. That would blank
// the label on every reactivation. Calling `updateCustomField` directly with
// only `{ is_active: true }` sidesteps `readValues` entirely, so the PUT
// body is `{ is_active: true }` and nothing else.
async activate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await updateCustomField(event, id, { is_active: true });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { activate: { error: 'Only an admin can turn custom fields on.' } });
}
return fail(400, {
activate: { error: readableError(err, 'Could not turn the field on.') }
});
}
return { activated: true };
}
};

View file

@ -12,16 +12,46 @@
* field with 23 records missing a value is a promise the data does not keep,
* and reporting on that field will quietly exclude or mis-bucket those 23.
*/
import { enhance } from '$app/forms';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import SettingsCrumb from '$lib/v2/components/SettingsCrumb.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import StatCard from '$lib/v2/components/StatCard.svelte';
import SettingsFormPanel from '$lib/v2/components/SettingsFormPanel.svelte';
import ConfirmAction from '$lib/v2/components/ConfirmAction.svelte';
import { count } from '$lib/v2/format.js';
import { FIELD_TYPE_LABEL, TARGET_MODEL_LABEL } from '$lib/v2/enums.js';
import { Plus, TriangleAlert, Filter } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
// `null` when the panel is closed, `'new'` when adding, or the field object
// when editing that row. One panel, two modes, so two rows can never be
// open for edit at once.
let editing = $state(/** @type {any} */ (null));
// The dropdown options being edited, as `{ value, label }` rows. Held here
// rather than read off `editing` so a row can be added or removed before
// submitting. A new row has an empty `value`; the server module slugifies
// one from the label, and never rewrites an existing value.
let optionRows = $state(/** @type {{ value: string, label: string }[]} */ ([]));
// The type currently selected in the form, so the options editor appears
// only for a dropdown. Seeded from the row being edited.
let fieldType = $state('text');
function openCreate() {
editing = 'new';
fieldType = 'text';
optionRows = [];
}
function openEdit(f) {
editing = f;
fieldType = f.field_type;
optionRows = (f.options ?? []).map((o) => ({ value: o.value, label: o.label }));
}
let totals = $derived(data.totals);
@ -49,7 +79,9 @@
<span class="v2-num">{count(totals.models_extended)}</span> record types
{/snippet}
{#snippet actions()}
<button class="v2-btn v2-btn-primary"><Plus />New field</button>
{#if data.can_edit && !editing}
<button class="v2-btn v2-btn-primary" onclick={openCreate}><Plus />New field</button>
{/if}
{/snippet}
</PageHeader>
@ -69,6 +101,178 @@
<div class="v2-scroll">
<div class="v2-pad" style="padding-bottom:32px">
{#if editing}
<SettingsFormPanel
title={editing === 'new' ? 'New custom field' : `Edit ${editing.label}`}
action={editing === 'new' ? '?/create' : '?/update'}
error={editing === 'new' ? form?.create?.error : form?.update?.error}
submitLabel={editing === 'new' ? 'Add field' : 'Save field'}
oncancel={() => (editing = null)}
ondone={() => (editing = null)}
>
{#snippet fields()}
{#if editing !== 'new'}
<input type="hidden" name="id" value={editing.id} />
{/if}
<div class="v2-field">
<label for="f-label">Label</label>
<input
id="f-label"
class="v2-input"
name="label"
maxlength="128"
required
value={editing === 'new' ? '' : editing.label}
/>
</div>
<div class="v2-field">
<label for="f-key">Key</label>
{#if editing === 'new'}
<input
id="f-key"
class="v2-input"
name="key"
maxlength="64"
pattern="[a-z][a-z0-9_]*"
required
placeholder="severity"
/>
<p class="v2-hint">
Lowercase letters, numbers and underscores, starting with a letter. No hyphens.
Cannot be changed later.
</p>
{:else}
<code class="v2-cf-key">{editing.key}</code>
<p class="v2-hint">
Fixed after creation. Every value already stored is filed under this key, and
changing it would leave them all behind.
</p>
{/if}
</div>
<div class="v2-field">
<label for="f-target">On record type</label>
{#if editing === 'new'}
<select id="f-target" class="v2-input" name="target_model" required>
{#each Object.entries(TARGET_MODEL_LABEL) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
{:else}
<div style="font-size:13px">
{TARGET_MODEL_LABEL[editing.target_model] ?? editing.target_model}
</div>
<p class="v2-hint">Fixed after creation.</p>
{/if}
</div>
<div class="v2-field">
<label for="f-type">Type</label>
{#if editing === 'new'}
<select id="f-type" class="v2-input" name="field_type" bind:value={fieldType}>
{#each Object.entries(FIELD_TYPE_LABEL) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
{:else}
<!-- No `<select>` here means nothing named `field_type` reaches
the form submit, but `fieldType` (below) still has to drive
whether the choices editor renders and, on submit, whether
`readValues`/`buildBody` attach `options` at all. This
hidden input carries the value through without offering it
as something to change; `UPDATE_FIELDS` drops `field_type`
from the outgoing body regardless of what this holds. -->
<input type="hidden" name="field_type" value={fieldType} />
<div style="font-size:13px">{FIELD_TYPE_LABEL[editing.field_type]}</div>
<p class="v2-hint">
Fixed after creation. Values already stored were written and checked against this
type.
</p>
{/if}
</div>
{#if fieldType === 'dropdown'}
<div class="v2-field v2-sfp-wide">
<label for="f-choices">Choices</label>
{#each optionRows as row, i (i)}
<div style="display:flex;gap:7px;align-items:center;margin-bottom:6px">
<input type="hidden" name="option_value" value={row.value} />
<input
id={i === 0 ? 'f-choices' : undefined}
class="v2-input"
name="option_label"
bind:value={row.label}
required
/>
<button
class="v2-btn v2-btn-sm"
type="button"
onclick={() => (optionRows = optionRows.filter((_, j) => j !== i))}
>
Remove
</button>
</div>
{/each}
<button
class="v2-btn v2-btn-sm"
type="button"
style="align-self:flex-start"
onclick={() => (optionRows = [...optionRows, { value: '', label: '' }])}
>
Add a choice
</button>
<p class="v2-hint">
Renaming a choice keeps the values already stored against it. Removing one leaves
the records that hold it showing a value the list no longer offers.
</p>
</div>
{/if}
<div class="v2-field">
<label for="f-order">Order</label>
<input
id="f-order"
class="v2-input"
type="number"
name="display_order"
min="0"
value={editing === 'new' ? 0 : editing.display_order}
/>
</div>
<div class="v2-field">
<label for="f-required">Required</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400">
<input
id="f-required"
type="checkbox"
name="is_required"
value="true"
checked={editing !== 'new' && editing.is_required}
/>
Binds new writes only. Records saved before this keep their gap.
</label>
</div>
<div class="v2-field">
<label for="f-filterable">Filterable</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400">
<input
id="f-filterable"
type="checkbox"
name="is_filterable"
value="true"
checked={editing !== 'new' && editing.is_filterable}
/>
Can be used to narrow a list, not just read on the record.
</label>
</div>
{/snippet}
</SettingsFormPanel>
{/if}
{#if gaps.length}
<div class="v2-cf-banner">
<TriangleAlert size={16} style="color:var(--v2-clay);flex:none;margin-top:1px" />
@ -89,6 +293,13 @@
</div>
{/if}
{#if form?.deactivate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.deactivate.error}</p>
{/if}
{#if form?.activate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.activate.error}</p>
{/if}
<div class="v2-cf-groups">
{#each groups as g (g.model)}
<div>
@ -124,6 +335,35 @@
{:else if f.is_required}
<Pill tone="clay">Required</Pill>
{/if}
{#if data.can_edit}
<div style="display:flex;gap:6px;align-items:center;flex:none">
<button class="v2-btn v2-btn-sm" type="button" onclick={() => openEdit(f)}>
Edit
</button>
{#if f.is_active}
<ConfirmAction
action="?/deactivate"
label="Turn off"
confirmLabel="Turn off"
explain="Stops being collected. Stored values stay."
hidden={{ id: f.id }}
/>
{:else}
<!-- Turning a field back on restores nothing that was
destroyed (the values never left), so unlike "Turn
off" this doesn't need the two-click confirm. A
plain enhanced form posting just the id keeps the
request to `{ is_active: true }`, see the `activate`
action's comment for why that has to be its own
action rather than a bare `update` submit. -->
<form method="POST" action="?/activate" use:enhance>
<input type="hidden" name="id" value={f.id} />
<button class="v2-btn v2-btn-sm" type="submit">Turn on</button>
</form>
{/if}
</div>
{/if}
</div>
{/each}
</div>

View file

@ -1,6 +1,146 @@
import { getEscalationPolicies } from '$lib/server/v2/escalation.js';
import { fail } from '@sveltejs/kit';
import {
getEscalationPolicies,
createEscalationPolicy,
updateEscalationPolicy,
deleteEscalationPolicy
} from '$lib/server/v2/escalation.js';
import { getOrgPeopleAndTeams } from '$lib/server/v2/org-people.js';
import { readableError } from '$lib/server/v2/form-errors.js';
// The policy list and the create/edit form's target picker are two
// independent fetches: `getEscalationPolicies` does not fold
// `getOrgPeopleAndTeams` into its own return, because `getSettingsHub` (the
// settings-hub aggregator) also calls `getEscalationPolicies` and only ever
// reads `.policies` off it. Folding the picker fetch in there would fire an
// extra, unused `/users/get-teams-and-users/` request on every settings-hub
// load. Fetched in parallel here instead, since this is the one place both
// are actually needed.
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
return getEscalationPolicies({ cookies });
const [data, options] = await Promise.all([
getEscalationPolicies({ cookies }),
getOrgPeopleAndTeams(cookies)
]);
return { ...data, ...options };
}
/**
* Everything both the create and edit form submit. `is_active` is
* deliberately not read here: only the create form carries that checkbox
* (the row's own Turn off / Turn on control owns that state on an existing
* policy), so reading `form.get('is_active')` here would answer `false` on
* every edit submit, since an absent checkbox and an unchecked one look
* identical to `FormData`. The `create` action below attaches `is_active`
* itself, straight from the one form that renders it.
*
* `priority` is read here too, but it only ever reaches the outgoing body on
* a create: `updateEscalationPolicy` filters through `UPDATE_FIELDS`, which
* excludes it, and the edit form never renders a `priority` input in the
* first place.
*
* @param {FormData} form
*/
function readValues(form) {
return {
priority: form.get('priority')?.toString() ?? '',
first_response_action: form.get('first_response_action')?.toString() ?? '',
resolution_action: form.get('resolution_action')?.toString() ?? '',
first_response_target_id: form.get('first_response_target_id')?.toString() ?? '',
resolution_target_id: form.get('resolution_target_id')?.toString() ?? '',
notify_team_id: form.get('notify_team_id')?.toString() ?? ''
};
}
/** @type {import('./$types').Actions} */
export const actions = {
// Admin-only server-side (`EscalationPolicyListCreateView.post` and
// `EscalationPolicyDetailView.put`/`.delete` each call `_is_admin` first).
// `can_edit` only hides the control.
async create(event) {
const form = await event.request.formData();
const values = { ...readValues(form), is_active: form.get('is_active') === 'true' };
try {
await createEscalationPolicy(event, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { create: { error: 'Only an admin can change escalation policies.' } });
}
return fail(400, { create: { error: readableError(err, 'Could not add the policy.') } });
}
return { created: true };
},
async update(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
const values = readValues(form);
try {
await updateEscalationPolicy(event, id, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { update: { error: 'Only an admin can change escalation policies.' } });
}
return fail(400, { update: { error: readableError(err, 'Could not save the policy.') } });
}
return { updated: true };
},
async deactivate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await updateEscalationPolicy(event, id, { is_active: false });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, {
deactivate: { error: 'Only an admin can change escalation policies.' }
});
}
return fail(400, {
deactivate: { error: readableError(err, 'Could not turn the policy off.') }
});
}
return { deactivated: true };
},
// A policy's own action, not a reuse of `update`. `readValues` builds a
// full policy payload from the form, and this control only ever submits an
// id: if it posted through `update`, every other field would read back
// empty (`FormData` has no key for it) and `buildBody` would forward that,
// blanking the policy on every reactivation. Calling `updateEscalationPolicy`
// directly with only `{ is_active: true }` sidesteps `readValues` entirely,
// so the PUT body is `{ is_active: true }` and nothing else.
async activate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await updateEscalationPolicy(event, id, { is_active: true });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { activate: { error: 'Only an admin can change escalation policies.' } });
}
return fail(400, {
activate: { error: readableError(err, 'Could not turn the policy on.') }
});
}
return { activated: true };
},
// `EscalationPolicyDetailView.delete` is a hard delete. The row control
// that posts here says so before it does, and names what happens next: the
// priority it covered escalates to nobody.
async remove(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await deleteEscalationPolicy(event, id);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { remove: { error: 'Only an admin can change escalation policies.' } });
}
return fail(400, { remove: { error: readableError(err, 'Could not delete the policy.') } });
}
return { removed: true };
}
};

View file

@ -3,9 +3,9 @@
* What happens when a ticket blows its target.
*
* One EscalationPolicy per priority, enforced by a unique constraint. The
* useful question is not "is there a policy", there always is, but "does
* it do anything", and the model allows three separate ways for the answer
* to be no:
* useful question is not "is there a policy", there always is at most one
* per priority, but "does it do anything", and the model allows three
* separate ways for the answer to be no:
*
* · the action is `reassign` but the target FK is null (SET_NULL empties
* it when a profile is removed, silently)
@ -15,28 +15,50 @@
* All three look identical in v1's form, a row of selects with something
* chosen. Here the breach count sits next to the outcome, so "11 breaches,
* nobody told" is one line rather than two screens.
*
* Only four priorities exist and each may have at most one policy
* (`(org, priority)` unique constraint, enforced again by
* `EscalationPolicySerializer.validate()` on create). So "New policy"
* offers only the priorities with no policy yet, and disappears once all
* four are configured: a disabled button with no explanation is the dead
* end this phase exists to remove, not a control worth keeping around.
*
* `priority` never appears on the edit form. `EscalationPolicyDetailView.put`
* strips the key from the request body before the serializer sees it, so an
* edit that offered it would report success and change nothing.
*/
import { enhance } from '$app/forms';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import SettingsCrumb from '$lib/v2/components/SettingsCrumb.svelte';
import EmptyState from '$lib/v2/components/EmptyState.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import SettingsFormPanel from '$lib/v2/components/SettingsFormPanel.svelte';
import ConfirmAction from '$lib/v2/components/ConfirmAction.svelte';
import { count } from '$lib/v2/format.js';
import { ESCALATION_ACTION_LABEL, PRIORITY_TONE } from '$lib/v2/enums.js';
import { TriangleAlert, BellOff } from '@lucide/svelte';
import { ESCALATION_ACTION_LABEL, ESCALATION_PRIORITIES, PRIORITY_TONE } from '$lib/v2/enums.js';
import { missingOption, inactiveOptionLabel } from '$lib/v2/pickers.js';
import { TriangleAlert, BellOff, Plus } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
/**
* By severity, not by the model's `ordering = ("priority",)`. That sorts
* the CharField alphabetically and puts Low between High and Normal, which
* reads as a mistake every single time.
*/
const SEVERITY = ['Urgent', 'High', 'Normal', 'Low'];
let policies = $derived(
[...data.policies].sort((a, b) => SEVERITY.indexOf(a.priority) - SEVERITY.indexOf(b.priority))
[...data.policies].sort(
(a, b) => ESCALATION_PRIORITIES.indexOf(a.priority) - ESCALATION_PRIORITIES.indexOf(b.priority)
)
);
/** The priorities with no policy yet: what "New policy" is allowed to offer. */
let availablePriorities = $derived(
ESCALATION_PRIORITIES.filter((p) => !data.policies.some((x) => x.priority === p))
);
let allConfigured = $derived(availablePriorities.length === 0);
/**
* What one half of a policy actually does, and whether that is nothing.
* @returns {{ text: string, dead: boolean }}
@ -71,17 +93,230 @@
0
)
);
// `null` when the panel is closed, `'new'` when adding, or the policy
// object when editing that row. One panel, two modes, so two rows can
// never be open for edit at once.
let editing = $state(/** @type {any} */ (null));
// Mirrors of the four action/target fields, so the "reassigns to nobody"
// hint can react to what is currently picked in the form, before the
// policy is saved, rather than only ever telling the admin from the card
// afterward. `notify_team_id` needs no such mirror: there is no live
// warning tied to it.
let firstResponseAction = $state('notify');
let resolutionAction = $state('notify');
let firstResponseTarget = $state('');
let resolutionTarget = $state('');
function openCreate() {
editing = 'new';
firstResponseAction = 'notify';
resolutionAction = 'notify';
firstResponseTarget = '';
resolutionTarget = '';
}
function openEdit(p) {
editing = p;
firstResponseAction = p.first_response_action;
resolutionAction = p.resolution_action;
firstResponseTarget = p.first_response_target?.id ?? '';
resolutionTarget = p.resolution_target?.id ?? '';
}
/**
* A stored target the picker cannot offer.
*
* `getOrgPeopleAndTeams` only ever returns active profiles, while
* `EscalationPolicy.first_response_target` / `resolution_target` carry
* whoever was chosen, deactivated or not, and `cases/tasks.py` escalates to
* them with no active filter. A select with no matching option submits
* nothing, `readValues` reads that absence as `''`, and `buildBody` turns
* `''` into `null`. So without an option of its own, opening this form to
* change one unrelated field would stop Urgent breaches escalating to
* anybody. It would also hide the "reassigns to nobody" warning at the
* moment it became true, because the binding keeps the stale id when the
* browser deselects.
*/
let missingFirstTarget = $derived(
editing && editing !== 'new' ? missingOption(data.people, editing.first_response_target) : null
);
let missingResolutionTarget = $derived(
editing && editing !== 'new' ? missingOption(data.people, editing.resolution_target) : null
);
</script>
<PageHeader title="Escalation">
{#snippet crumb()}<SettingsCrumb />{/snippet}
{#snippet sub()}
One policy per priority · <span class="v2-num">{count(policies.length)}</span> configured
{#if allConfigured}
One policy per priority · all four are configured
{:else}
One policy per priority · <span class="v2-num">{count(policies.length)}</span> configured
{/if}
{/snippet}
{#snippet actions()}
{#if data.can_edit && !editing && availablePriorities.length > 0}
<button class="v2-btn v2-btn-primary" onclick={openCreate}><Plus />New policy</button>
{/if}
{/snippet}
</PageHeader>
<div class="v2-scroll">
<div class="v2-pad" style="padding-top:18px;padding-bottom:32px">
{#if editing}
<SettingsFormPanel
title={editing === 'new' ? 'New policy' : `Edit ${editing.priority} policy`}
action={editing === 'new' ? '?/create' : '?/update'}
error={editing === 'new' ? form?.create?.error : form?.update?.error}
submitLabel={editing === 'new' ? 'Add policy' : 'Save policy'}
oncancel={() => (editing = null)}
ondone={() => (editing = null)}
>
{#snippet fields()}
{#if editing !== 'new'}
<input type="hidden" name="id" value={editing.id} />
{/if}
<div class="v2-field">
<label for="e-priority">Priority</label>
{#if editing === 'new'}
<select id="e-priority" class="v2-input" name="priority" required>
{#each availablePriorities as p (p)}
<option value={p}>{p}</option>
{/each}
</select>
{:else}
<div style="font-size:13px">{editing.priority}</div>
<p class="v2-hint">Fixed after creation. One policy per priority.</p>
{/if}
</div>
<div class="v2-field">
<label for="e-fr-action">First response action</label>
<select
id="e-fr-action"
class="v2-input"
name="first_response_action"
bind:value={firstResponseAction}
>
{#each Object.entries(ESCALATION_ACTION_LABEL) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
{#if firstResponseAction === 'reassign' && !firstResponseTarget}
<p class="v2-hint">Reassigns to nobody until a target is picked below.</p>
{/if}
</div>
<div class="v2-field">
<label for="e-fr-target">First response target</label>
<select
id="e-fr-target"
class="v2-input"
name="first_response_target_id"
bind:value={firstResponseTarget}
>
<option value="">Nobody</option>
{#if missingFirstTarget}
<option value={missingFirstTarget.id}>
{inactiveOptionLabel(missingFirstTarget.name)}
</option>
{/if}
{#each data.people as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
{#if missingFirstTarget}
<p class="v2-hint">
This target's account is no longer active. It stays set until you change it, and a
breach reassigned there waits for someone who cannot sign in.
</p>
{/if}
</div>
<div class="v2-field">
<label for="e-res-action">Resolution action</label>
<select
id="e-res-action"
class="v2-input"
name="resolution_action"
bind:value={resolutionAction}
>
{#each Object.entries(ESCALATION_ACTION_LABEL) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
{#if resolutionAction === 'reassign' && !resolutionTarget}
<p class="v2-hint">Reassigns to nobody until a target is picked below.</p>
{/if}
</div>
<div class="v2-field">
<label for="e-res-target">Resolution target</label>
<select
id="e-res-target"
class="v2-input"
name="resolution_target_id"
bind:value={resolutionTarget}
>
<option value="">Nobody</option>
{#if missingResolutionTarget}
<option value={missingResolutionTarget.id}>
{inactiveOptionLabel(missingResolutionTarget.name)}
</option>
{/if}
{#each data.people as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
{#if missingResolutionTarget}
<p class="v2-hint">
This target's account is no longer active. It stays set until you change it, and a
breach reassigned there waits for someone who cannot sign in.
</p>
{/if}
</div>
<div class="v2-field">
<label for="e-team">Notify team</label>
<select id="e-team" class="v2-input" name="notify_team_id">
<option value="" selected={editing === 'new' || !editing.notify_team}>No team</option>
{#each data.teams as t (t.id)}
<option value={t.id} selected={editing !== 'new' && editing.notify_team?.id === t.id}>
{t.name}
</option>
{/each}
</select>
{#if !data.teams.length}
<p class="v2-hint">No teams in this org yet.</p>
{/if}
</div>
{#if editing === 'new'}
<div class="v2-field">
<label for="e-active">Active</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400">
<input id="e-active" type="checkbox" name="is_active" value="true" checked />
Starts escalating breaches at this priority as soon as it is saved.
</label>
</div>
{/if}
{/snippet}
</SettingsFormPanel>
{/if}
{#if form?.deactivate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.deactivate.error}</p>
{/if}
{#if form?.activate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.activate.error}</p>
{/if}
{#if form?.remove?.error}
<p class="v2-error" style="margin-bottom:12px">{form.remove.error}</p>
{/if}
{#if policies.length === 0}
<!-- No policy for any priority. Without this the page falls to a header
over a blank column, the each-block renders nothing and the trailing
@ -120,9 +355,42 @@
{@const first = outcome(p, 'first_response')}
{@const res = outcome(p, 'resolution')}
<div class="v2-card" style="padding:15px 16px;opacity:{p.is_active ? 1 : 0.62}">
<div style="display:flex;gap:9px;align-items:center;margin-bottom:12px">
<Pill tone={PRIORITY_TONE[p.priority]}>{p.priority}</Pill>
{#if !p.is_active}<Pill tone="slate">Off</Pill>{/if}
<div
style="display:flex;gap:9px;align-items:center;margin-bottom:12px;justify-content:space-between"
>
<div style="display:flex;gap:9px;align-items:center">
<Pill tone={PRIORITY_TONE[p.priority]}>{p.priority}</Pill>
{#if !p.is_active}<Pill tone="slate">Off</Pill>{/if}
</div>
{#if data.can_edit}
<div style="display:flex;gap:6px;align-items:center;flex:none">
<button class="v2-btn v2-btn-sm" type="button" onclick={() => openEdit(p)}>
Edit
</button>
{#if p.is_active}
<ConfirmAction
action="?/deactivate"
label="Turn off"
confirmLabel="Turn off"
explain="Stops escalating breaches at this priority. It stays in the list, off, until turned back on."
hidden={{ id: p.id }}
/>
{:else}
<form method="POST" action="?/activate" use:enhance>
<input type="hidden" name="id" value={p.id} />
<button class="v2-btn v2-btn-sm" type="submit">Turn on</button>
</form>
{/if}
<ConfirmAction
action="?/remove"
label="Delete"
confirmLabel="Delete"
explain="Deleted permanently. Breaches at this priority will escalate to nobody."
hidden={{ id: p.id }}
/>
</div>
{/if}
</div>
<div class="v2-escalation-halves">
@ -188,7 +456,6 @@
@media (max-width: 768px) {
.v2-escalation-halves {
grid-template-columns: 1fr;
gap: 14px;
}
.v2-escalation-half + .v2-escalation-half {
border-left: 0;

View file

@ -1,6 +1,142 @@
import { getMailboxes } from '$lib/server/v2/inbound-email.js';
import { fail } from '@sveltejs/kit';
import {
getMailboxes,
createMailbox,
updateMailbox,
deleteMailbox
} from '$lib/server/v2/inbound-email.js';
import { getOrgPeopleAndTeams } from '$lib/server/v2/org-people.js';
import { readableError } from '$lib/server/v2/form-errors.js';
// The mailbox list and the create/edit form's assignee picker are two
// independent fetches: `getMailboxes` does not fold `getOrgPeopleAndTeams`
// into its own return, because `getSettingsHub` (the settings-hub aggregator)
// also calls `getMailboxes` and only ever reads `.totals` off it. Folding the
// picker fetch in there would fire an extra, unused
// `/users/get-teams-and-users/` request on every settings-hub load. Fetched
// in parallel here instead, since this is the one place both are actually
// needed. This resource has no team FK, so only `.people` is read below, but
// `getOrgPeopleAndTeams` returns both and spreading both is harmless.
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
return getMailboxes({ cookies });
const [data, options] = await Promise.all([
getMailboxes({ cookies }),
getOrgPeopleAndTeams(cookies)
]);
return { ...data, ...options };
}
/**
* Everything both the create and edit form submit. There is no signing-secret
* key read here: no field on either form carries one, and reading one would be
* the first step toward sending it. `is_active` is likewise deliberately not
* read here: only the create form carries that checkbox (the edit form's own
* row control, Turn off / Turn on, owns that state), so reading
* `form.get('is_active')` here would answer `false` on every edit submit,
* since an absent checkbox and an unchecked one look identical to `FormData`.
* The `create` action below attaches `is_active` itself, straight from the
* one form that renders it.
*
* @param {FormData} form
*/
function readValues(form) {
return {
address: form.get('address')?.toString().trim() ?? '',
provider: form.get('provider')?.toString() ?? '',
default_priority: form.get('default_priority')?.toString() ?? '',
default_case_type: form.get('default_case_type')?.toString() ?? '',
default_assignee_id: form.get('default_assignee_id')?.toString() ?? ''
};
}
/** @type {import('./$types').Actions} */
export const actions = {
// Admin-only server-side (`InboundMailboxListCreateView.post` calls
// `_is_admin` first). `can_edit` only hides the control.
async create(event) {
const form = await event.request.formData();
const values = { ...readValues(form), is_active: form.get('is_active') === 'true' };
try {
await createMailbox(event, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { create: { error: 'Only an admin can change inbound mailboxes.' } });
}
return fail(400, { create: { error: readableError(err, 'Could not add the address.') } });
}
return { created: true };
},
async update(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
const values = readValues(form);
try {
await updateMailbox(event, id, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { update: { error: 'Only an admin can change inbound mailboxes.' } });
}
return fail(400, { update: { error: readableError(err, 'Could not save the address.') } });
}
return { updated: true };
},
async deactivate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await updateMailbox(event, id, { is_active: false });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { deactivate: { error: 'Only an admin can change inbound mailboxes.' } });
}
return fail(400, {
deactivate: { error: readableError(err, 'Could not turn the address off.') }
});
}
return { deactivated: true };
},
// A row's own action, not a reuse of `update`. `readValues` builds a full
// mailbox payload from the form, and this control only ever submits an id:
// if it posted through `update`, `address` would read back as `''`
// (`FormData` has no key for it) and `createMailbox`/`updateMailbox`'s own
// `buildBody` would forward that, and `validate_address` would reject the
// empty string on its way to blanking the address. Calling `updateMailbox`
// directly with only `{ is_active: true }` sidesteps `readValues` entirely,
// so the PUT body is `{ is_active: true }` and nothing else.
async activate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await updateMailbox(event, id, { is_active: true });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { activate: { error: 'Only an admin can change inbound mailboxes.' } });
}
return fail(400, {
activate: { error: readableError(err, 'Could not turn the address on.') }
});
}
return { activated: true };
},
// `InboundMailboxDetailView.delete` is a hard delete. It also destroys the
// row's signing secret, so any delivery already signed against it stops
// verifying, not just stops opening tickets. The row control that posts
// here says so before it does.
async remove(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await deleteMailbox(event, id);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { remove: { error: 'Only an admin can change inbound mailboxes.' } });
}
return fail(400, { remove: { error: readableError(err, 'Could not delete the address.') } });
}
return { removed: true };
}
};

View file

@ -4,30 +4,70 @@
*
* Two things this page is careful about:
*
* 1. NO SECRETS. InboundMailbox carries `webhook_secret` and
* `imap_password_enc`. The webhook secret is what proves a delivery
* really came from the provider. Anything holding it can forge tickets
* into this org, or read the ones it forges. It is not fetched, not
* rendered, and not masked-but-present in the DOM. Rotation belongs
* behind an explicit action, not on a page you can arrive at by browsing.
* 1. NO SECRETS. InboundMailbox carries a per-address signing secret and a
* provider topic id. The signing secret is what proves a delivery really
* came from the provider. Anything holding it can forge tickets into this
* org, or read the ones it forges. It is not fetched, not rendered, and
* not masked-but-present in the DOM, on this page or on the create/edit
* form below: there is no field for it, not even a disabled one, because
* the server always generates a stronger secret than a pasted one and an
* empty field posted on an edit would blank the org's live credential.
* Rotation and a one-time reveal on create are a deliberate, separate
* feature, not built here; see the card at the foot of the page.
* 2. An inactive mailbox does not bounce. The address keeps accepting mail
* and the webhook stops creating cases, so the sender gets silence rather
* than a delivery failure. That is a materially different thing from
* "off", and a grey pill saying "Off" does not say it.
*/
import { enhance } from '$app/forms';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import SettingsCrumb from '$lib/v2/components/SettingsCrumb.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import SettingsFormPanel from '$lib/v2/components/SettingsFormPanel.svelte';
import ConfirmAction from '$lib/v2/components/ConfirmAction.svelte';
import { count, relativeDays } from '$lib/v2/format.js';
import { MAILBOX_PROVIDER_LABEL, PRIORITY_TONE } from '$lib/v2/enums.js';
import { missingOption, inactiveOptionLabel } from '$lib/v2/pickers.js';
import { Plus, MailWarning, KeyRound } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
// cases.InboundMailbox.PROVIDER_CHOICES is MAILBOX_PROVIDER_LABEL's key
// order. PRIORITY_CHOICE and CASE_TYPE mirror ticket-approvals' own local
// copies: value and label are the same string for both, and no shared map
// exists to derive them from.
const PROVIDERS = ['ses', 'mailgun', 'postmark', 'imap'];
const PRIORITIES = ['Low', 'Normal', 'High', 'Urgent'];
const CASE_TYPES = ['Question', 'Incident', 'Problem'];
let totals = $derived(data.totals);
let mailboxes = $derived(data.mailboxes);
let silent = $derived(mailboxes.filter((m) => !m.is_active));
// `null` when the panel is closed, `'new'` when adding, or the mailbox
// object when editing that row. One panel, two modes, so two rows can
// never be open for edit at once.
let editing = $state(/** @type {any} */ (null));
function openCreate() {
editing = 'new';
}
function openEdit(m) {
editing = m;
}
// The stored default assignee when the picker cannot offer it, because the
// profile has been deactivated since it was chosen. `getOrgPeopleAndTeams`
// returns active profiles only, `InboundMailbox.default_assignee` keeps
// whoever was set. A select with no matching option submits nothing, which
// `readValues` reads as `''` and `buildBody` sends as `null`, so an edit
// made to change the address or the provider would also quietly move this
// mailbox back to Unassigned.
let missingAssignee = $derived(
editing && editing !== 'new' ? missingOption(data.people, editing.default_assignee) : null
);
</script>
<PageHeader title="Inbound email">
@ -38,12 +78,136 @@
<span class="v2-num">{count(totals.cases_last_30d)}</span> in the last 30 days
{/snippet}
{#snippet actions()}
<button class="v2-btn v2-btn-primary"><Plus />Add address</button>
{#if data.can_edit && !editing}
<button class="v2-btn v2-btn-primary" onclick={openCreate}><Plus />Add address</button>
{/if}
{/snippet}
</PageHeader>
<div class="v2-scroll">
<div class="v2-pad" style="padding-top:18px;padding-bottom:32px">
{#if editing}
<SettingsFormPanel
title={editing === 'new' ? 'New address' : `Edit ${editing.address}`}
action={editing === 'new' ? '?/create' : '?/update'}
error={editing === 'new' ? form?.create?.error : form?.update?.error}
submitLabel={editing === 'new' ? 'Add address' : 'Save address'}
oncancel={() => (editing = null)}
ondone={() => (editing = null)}
>
{#snippet fields()}
{#if editing !== 'new'}
<input type="hidden" name="id" value={editing.id} />
{/if}
<div class="v2-field">
<label for="m-address">Address</label>
<input
id="m-address"
class="v2-input"
type="email"
name="address"
required
value={editing === 'new' ? '' : editing.address}
/>
{#if editing !== 'new'}
<p class="v2-hint">Mail to the old address stops becoming tickets.</p>
{/if}
</div>
<div class="v2-field">
<label for="m-provider">Provider</label>
<select id="m-provider" class="v2-input" name="provider">
{#each PROVIDERS as p (p)}
<option value={p} selected={editing === 'new' ? p === 'ses' : editing.provider === p}>
{MAILBOX_PROVIDER_LABEL[p]}
</option>
{/each}
</select>
<p class="v2-hint">
The signing secret is generated by the server and is not shown here.
</p>
</div>
<div class="v2-field">
<label for="m-priority">Opens as priority</label>
<select id="m-priority" class="v2-input" name="default_priority">
{#each PRIORITIES as p (p)}
<option
value={p}
selected={editing === 'new' ? p === 'Normal' : editing.default_priority === p}
>
{p}
</option>
{/each}
</select>
</div>
<div class="v2-field">
<label for="m-type">Opens as type</label>
<select id="m-type" class="v2-input" name="default_case_type">
<option value="" selected={editing === 'new' || !editing.default_case_type}>
None
</option>
{#each CASE_TYPES as t (t)}
<option value={t} selected={editing !== 'new' && editing.default_case_type === t}>
{t}
</option>
{/each}
</select>
</div>
<div class="v2-field">
<label for="m-assignee">Default assignee</label>
<select id="m-assignee" class="v2-input" name="default_assignee_id">
<option value="" selected={editing === 'new' || !editing.default_assignee}>
Unassigned
</option>
{#if missingAssignee}
<option value={missingAssignee.id} selected>
{inactiveOptionLabel(missingAssignee.name)}
</option>
{/if}
{#each data.people as p (p.id)}
<option
value={p.id}
selected={editing !== 'new' && editing.default_assignee?.id === p.id}
>
{p.name}
</option>
{/each}
</select>
{#if missingAssignee}
<p class="v2-hint">
This assignee's account is no longer active. It stays set until you change it, so
new tickets from this address land on someone who cannot sign in.
</p>
{/if}
</div>
{#if editing === 'new'}
<div class="v2-field">
<label for="m-active">Active</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400">
<input id="m-active" type="checkbox" name="is_active" value="true" checked />
Starts creating tickets from mail to this address as soon as it is saved.
</label>
</div>
{/if}
{/snippet}
</SettingsFormPanel>
{/if}
{#if form?.deactivate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.deactivate.error}</p>
{/if}
{#if form?.activate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.activate.error}</p>
{/if}
{#if form?.remove?.error}
<p class="v2-error" style="margin-bottom:12px">{form.remove.error}</p>
{/if}
{#if silent.length}
<div class="v2-mbx-banner">
<MailWarning size={17} style="color:var(--v2-clay);flex:none;margin-top:1px" />
@ -98,7 +262,34 @@
</div>
</div>
<button class="v2-btn v2-btn-sm" style="flex:none">Edit</button>
{#if data.can_edit}
<div style="display:flex;gap:6px;align-items:center;flex:none">
<button class="v2-btn v2-btn-sm" type="button" onclick={() => openEdit(m)}>
Edit
</button>
{#if m.is_active}
<ConfirmAction
action="?/deactivate"
label="Turn off"
confirmLabel="Turn off"
explain="Stops opening tickets from this address. It stays in the list, off, until turned back on."
hidden={{ id: m.id }}
/>
{:else}
<form method="POST" action="?/activate" use:enhance>
<input type="hidden" name="id" value={m.id} />
<button class="v2-btn v2-btn-sm" type="submit">Turn on</button>
</form>
{/if}
<ConfirmAction
action="?/remove"
label="Delete"
confirmLabel="Delete"
explain="Deleted permanently. Mail to this address stops becoming tickets, and the signing secret is destroyed."
hidden={{ id: m.id }}
/>
</div>
{/if}
</div>
{/each}
</div>
@ -106,8 +297,9 @@
<!--
The webhook secret is the credential that authenticates deliveries from
the provider. It is not on this page in any form, not shown, not
masked, not sitting in the payload behind a click-to-reveal. See api.js
for the same rule stated at the boundary.
masked, not sitting in the payload behind a click-to-reveal, and there
is no field for it on the form above either. See api.js for the same
rule stated at the boundary.
-->
<div class="v2-card" style="padding:15px 16px;margin-top:20px">
<div style="display:flex;gap:10px;align-items:flex-start">
@ -117,8 +309,8 @@
<p class="v2-sub" style="font-size:12.5px;margin:5px 0 0;line-height:1.5">
Each address has a shared secret that proves a delivery really came from the provider.
Anything holding it can post mail into this organisation as though a customer sent it,
so it is never rendered on a page. Rotating one is an explicit action that shows the
new value once.
so it is never rendered on a page. The server generates it when an address is added and
it is not editable here; rotating one is a separate action this page does not offer yet.
</p>
</div>
</div>

View file

@ -1,6 +1,126 @@
import { getMacros } from '$lib/server/v2/macros.js';
import { fail } from '@sveltejs/kit';
import {
getMacros,
createMacro,
updateMacro,
deleteMacro,
activateMacro
} from '$lib/server/v2/macros.js';
import { readableError } from '$lib/server/v2/form-errors.js';
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
return getMacros({ cookies });
}
// `is_active` is deliberately not read here: the create/edit form has no
// control for it (see the page's `fields` snippet), so a macro write from
// this form never carries it. That closes the gap "Turn off"'s two-click
// `ConfirmAction` used to have a sibling: unchecking a box in the edit panel
// and hitting Save used to flip `is_active` with no confirm and none of the
// consequence copy the dedicated action carries. Turning a macro back on now
// only happens through the `activate` action below, which sends nothing but
// the id and `is_active: true`.
/** @param {FormData} form */
function readValues(form) {
return {
title: form.get('title')?.toString() ?? '',
body: form.get('body')?.toString() ?? '',
scope: form.get('scope')?.toString() ?? ''
};
}
/** @type {import('./$types').Actions} */
export const actions = {
// Every signed-in member may create their own personal macro here.
// `can_create_org` only decides whether the form offers `scope=org` at
// all; `_resolve_scope_and_owner` re-derives admin status server-side and
// is what actually turns a non-admin's org-scope attempt into 403.
async create(event) {
const form = await event.request.formData();
const values = readValues(form);
try {
await createMacro(event, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, {
create: { error: 'Only an admin can create a macro shared with everyone.' }
});
}
return fail(400, { create: { error: readableError(err, 'Could not add the macro.') } });
}
return { created: true };
},
async update(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
const values = readValues(form);
try {
await updateMacro(event, id, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, {
update: { error: 'Only an admin can change a macro shared with everyone.' }
});
}
if (err?.status === 404) {
// A personal macro belonging to someone else answers 404 on purpose:
// a 403 would confirm the row exists. Say the same thing back.
return fail(404, { update: { error: 'That macro is not yours to change.' } });
}
return fail(400, { update: { error: readableError(err, 'Could not save the macro.') } });
}
return { updated: true };
},
async delete(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await deleteMacro(event, id);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, {
delete: { error: 'Only an admin can remove a macro shared with everyone.' }
});
}
if (err?.status === 404) {
// Same reasoning as `update`: the row not being yours reads the same
// as the row not existing, on purpose.
return fail(404, { delete: { error: 'That macro is not yours to remove.' } });
}
return fail(400, { delete: { error: readableError(err, 'Could not remove the macro.') } });
}
return { deleted: true };
},
// A macro's own action, not a reuse of `update`. `readValues` builds a
// full macro payload from the form, and this control only ever submits an
// id: posting through `update` would read `title`/`body` back as `''`
// (FormData has no key for either, `readValues` falls back to an empty
// string) and `buildBody` would then throw ("A macro needs a title.")
// before a request was even made. Calling `activateMacro` sidesteps
// `readValues`/`buildBody` entirely, so the PATCH body is
// `{ is_active: true }` and nothing else.
async activate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await activateMacro(event, id);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, {
activate: { error: 'Only an admin can turn on a macro shared with everyone.' }
});
}
if (err?.status === 404) {
// Same reasoning as `update`/`delete`: the row not being yours reads
// the same as the row not existing, on purpose.
return fail(404, { activate: { error: 'That macro is not yours to turn on.' } });
}
return fail(400, { activate: { error: readableError(err, 'Could not turn the macro on.') } });
}
return { activated: true };
}
};

View file

@ -15,17 +15,64 @@
* segments and placed in elements, never through {@html}. A canned reply is
* a string a colleague wrote; putting it in the DOM as markup would make the
* macro editor a stored-XSS form.
*
* PERMISSIONS, WHICH ARE NOT ROLE-GATED THE WAY THE REST OF SETTINGS IS
* Every signed-in member may create, edit and delete their own `personal`
* macros; only an admin may do any of that to an `org`-scope one.
* `data.can_create_org` and `data.my_profile_id` are display hints decoded
* server-side from the JWT (see `macros.js`'s `getMacros`), never the
* authorization: the backend re-derives both and is what actually decides
* whether a write succeeds. `canWrite` below mirrors that split so a row
* only offers Edit/Delete when the click would not just come back as an
* error.
*/
import { enhance } from '$app/forms';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import SettingsCrumb from '$lib/v2/components/SettingsCrumb.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import StatCard from '$lib/v2/components/StatCard.svelte';
import SettingsFormPanel from '$lib/v2/components/SettingsFormPanel.svelte';
import ConfirmAction from '$lib/v2/components/ConfirmAction.svelte';
import { count, relativeDays } from '$lib/v2/format.js';
import { MACRO_SCOPE_LABEL } from '$lib/v2/enums.js';
import { Plus, TriangleAlert } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
// `null` when the panel is closed, `'new'` when adding, or the macro
// object when editing that row. One panel, two modes, so two rows can
// never be open for edit at once.
let editing = $state(/** @type {any} */ (null));
// The scope currently selected in the form. Bound separately from `editing`
// so the select works the same way in both modes: seeded from the row on
// edit, defaulted to 'personal' on create, since every member can make a
// personal macro but not every member can make an org one.
let scope = $state('personal');
function openCreate() {
editing = 'new';
scope = 'personal';
}
function openEdit(m) {
editing = m;
scope = m.scope;
}
/**
* May the signed-in viewer write this row. An org macro is writable only
* by an admin (`data.can_create_org`); a personal macro is writable only
* by its owner, compared by Profile id (`m.owner.id`, from `getMacros`'s
* reshape, against `data.my_profile_id`). This is a display decision, not
* an authorization one: the backend enforces the same split independently
* (403 on someone else's org macro, 404 on someone else's personal one)
* and would refuse the write even if this returned true by mistake.
*/
function canWrite(m) {
return m.scope === 'org' ? data.can_create_org : m.owner?.id === data.my_profile_id;
}
let totals = $derived(data.totals);
let byUse = $derived([...data.macros].sort((a, b) => b.usage_count - a.usage_count));
@ -66,7 +113,9 @@
<span class="v2-num">{count(totals.personal)}</span> yours
{/snippet}
{#snippet actions()}
<button class="v2-btn v2-btn-primary"><Plus />New macro</button>
{#if !editing}
<button class="v2-btn v2-btn-primary" onclick={openCreate}><Plus />New macro</button>
{/if}
{/snippet}
</PageHeader>
@ -86,6 +135,67 @@
<div class="v2-scroll">
<div class="v2-pad" style="padding-bottom:32px">
{#if editing}
<SettingsFormPanel
title={editing === 'new' ? 'New macro' : `Edit ${editing.title}`}
action={editing === 'new' ? '?/create' : '?/update'}
error={editing === 'new' ? form?.create?.error : form?.update?.error}
submitLabel={editing === 'new' ? 'Add macro' : 'Save macro'}
oncancel={() => (editing = null)}
ondone={() => (editing = null)}
>
{#snippet fields()}
{#if editing !== 'new'}
<input type="hidden" name="id" value={editing.id} />
{/if}
<div class="v2-field">
<label for="m-title">Title</label>
<input
id="m-title"
class="v2-input"
name="title"
maxlength="255"
required
value={editing === 'new' ? '' : editing.title}
/>
</div>
<div class="v2-field">
<label for="m-scope">Who sees it</label>
<select id="m-scope" class="v2-input" name="scope" bind:value={scope}>
<option value="personal">Just me</option>
{#if data.can_create_org}
<option value="org">Everyone in the org</option>
{/if}
</select>
{#if !data.can_create_org}
<p class="v2-hint">Only an admin can share a macro with everyone.</p>
{/if}
</div>
<div class="v2-field v2-sfp-wide">
<label for="m-body">Body</label>
<textarea id="m-body" class="v2-input" name="body" rows="5" required
>{editing === 'new' ? '' : editing.body}</textarea
>
<p class="v2-hint">
Placeholders like %customer_name% are substituted when the macro is sent. The seven
supported tokens are listed to the right; anything else goes to the customer exactly
as typed.
</p>
</div>
{/snippet}
</SettingsFormPanel>
{/if}
{#if form?.delete?.error}
<p class="v2-error" style="margin-bottom:12px">{form.delete.error}</p>
{/if}
{#if form?.activate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.activate.error}</p>
{/if}
<div class="v2-split-wide">
<div>
<div class="v2-label" style="margin-bottom:10px">Shared with everyone</div>
@ -172,6 +282,51 @@
{MACRO_SCOPE_LABEL[m.scope]} · {m.owner.name}
</div>
{/if}
{#if canWrite(m)}
<div style="display:flex;gap:6px;align-items:center;justify-content:flex-end;margin-top:10px">
<button class="v2-btn v2-btn-sm" type="button" onclick={() => openEdit(m)}>Edit</button>
{#if !m.is_active}
<!-- Turning a macro back on restores nothing that was destroyed, so
unlike "Turn off"/"Delete" this doesn't need the two-click
confirm. A plain enhanced form posting just the id keeps the
request to `{ is_active: true }`; see `activateMacro`'s
comment for why that has to bypass `updateMacro` rather than
reuse it. Offered for both scopes on purpose: an org row gets
here by "Turn off" (soft), a personal row can only be inactive
from data written before the edit form stopped carrying
`is_active`, and either way `_get_writable` still enforces
admin-only for org / owner-only for personal server-side, so
this can never write a row `canWrite` above disagrees with. -->
<form method="POST" action="?/activate" use:enhance>
<input type="hidden" name="id" value={m.id} />
<button class="v2-btn v2-btn-sm" type="submit">Turn on</button>
</form>
{:else if m.scope === 'org'}
<!-- `MacroDetailView.delete` soft-deletes an org macro: it flips
`is_active` and leaves the row (and its usage count) in place.
"Turn off", not "Delete", says what actually happens. -->
<ConfirmAction
action="?/delete"
label="Turn off"
confirmLabel="Turn off"
explain="Turns it off for everyone. It stops appearing in the picker."
hidden={{ id: m.id }}
/>
{:else}
<!-- A personal macro is hard-deleted, not soft-deactivated: this
button removes the row outright, so once it's gone there is
nothing left to turn back on. -->
<ConfirmAction
action="?/delete"
label="Delete"
confirmLabel="Delete"
explain="Deletes it permanently."
hidden={{ id: m.id }}
/>
{/if}
</div>
{/if}
</div>
{/snippet}

View file

@ -1,6 +1,43 @@
import { getReopenPolicy } from '$lib/server/v2/reopen.js';
import { fail } from '@sveltejs/kit';
import { getReopenPolicy, updateReopenPolicy } from '$lib/server/v2/reopen.js';
import { readableError } from '$lib/server/v2/form-errors.js';
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
return getReopenPolicy({ cookies });
}
/** @type {import('./$types').Actions} */
export const actions = {
// Admin-only. `load`'s `can_edit` hides the "Edit policy" control for a
// member, but that is the affordance, not the check. `ReopenPolicyView.put`
// 403s a non-admin regardless of what reaches it; this branch turns that
// into a sentence instead of a raw backend error string.
async update(event) {
const form = await event.request.formData();
// The two booleans are read as "was the checkbox submitted", the normal
// HTML convention: an unchecked box sends nothing at all.
const values = {
is_enabled: form.get('is_enabled') === 'true',
notify_assigned: form.get('notify_assigned') === 'true',
reopen_window_days: form.get('reopen_window_days')?.toString() ?? '',
reopen_to_status: form.get('reopen_to_status')?.toString() ?? ''
};
try {
await updateReopenPolicy(event, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { update: { error: 'Only an admin can change the reopen policy.' } });
}
return fail(400, {
update: { error: readableError(err, 'Could not save the reopen policy.') }
});
}
// No redirect. The policy is on this page and `load` re-runs after an
// action, so the new values appear where the user is already looking.
return { updated: true };
}
};

View file

@ -15,11 +15,15 @@
import SettingsCrumb from '$lib/v2/components/SettingsCrumb.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import StatCard from '$lib/v2/components/StatCard.svelte';
import SettingsFormPanel from '$lib/v2/components/SettingsFormPanel.svelte';
import { count } from '$lib/v2/format.js';
import { REOPEN_TO_STATUSES } from '$lib/v2/enums.js';
import { RotateCcw, MailX } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
let editing = $state(false);
let p = $derived(data.policy);
</script>
@ -32,7 +36,9 @@
: 'Closed tickets stay closed'}
{/snippet}
{#snippet actions()}
<button class="v2-btn v2-btn-primary">Edit policy</button>
{#if data.can_edit && !editing}
<button class="v2-btn v2-btn-primary" onclick={() => (editing = true)}>Edit policy</button>
{/if}
{/snippet}
</PageHeader>
@ -61,6 +67,74 @@
<div class="v2-scroll">
<div class="v2-pad" style="padding-bottom:32px">
{#if editing}
<SettingsFormPanel
title="Reopen policy"
action="?/update"
error={form?.update?.error}
submitLabel="Save policy"
oncancel={() => (editing = false)}
ondone={() => (editing = false)}
>
{#snippet fields()}
<div class="v2-field v2-sfp-wide">
<label for="f-enabled">Reopen on customer reply</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400">
<input
id="f-enabled"
type="checkbox"
name="is_enabled"
value="true"
checked={p.is_enabled}
/>
Off means a reply is filed on the closed ticket and nothing else happens.
</label>
</div>
<div class="v2-field">
<label for="f-window">Window, in days</label>
<input
id="f-window"
class="v2-input"
type="number"
name="reopen_window_days"
min="1"
max="365"
required
value={p.reopen_window_days}
/>
<p class="v2-hint">Counted from when the ticket was closed, in calendar days.</p>
</div>
<div class="v2-field">
<label for="f-status">Comes back as</label>
<select id="f-status" class="v2-input" name="reopen_to_status">
{#each REOPEN_TO_STATUSES as status (status)}
<option value={status} selected={status === p.reopen_to_status}>{status}</option>
{/each}
</select>
<p class="v2-hint">
Only these three. A ticket reopened into a closed status would close again on
arrival.
</p>
</div>
<div class="v2-field v2-sfp-wide">
<label for="f-notify">Tell the assignee</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400">
<input
id="f-notify"
type="checkbox"
name="notify_assigned"
value="true"
checked={p.notify_assigned}
/>
The person the ticket was assigned to when it closed.
</label>
</div>
{/snippet}
</SettingsFormPanel>
{/if}
<div class="v2-split">
<div>
<div class="v2-label" style="margin-bottom:10px">The rule</div>

View file

@ -1,6 +1,145 @@
import { getRoutingRules } from '$lib/server/v2/routing.js';
import { fail } from '@sveltejs/kit';
import {
getRoutingRules,
createRoutingRule,
updateRoutingRule,
deleteRoutingRule,
readConditionRows
} from '$lib/server/v2/routing.js';
import { getOrgPeopleAndTeams } from '$lib/server/v2/org-people.js';
import { readableError } from '$lib/server/v2/form-errors.js';
// The rule list and the create/edit form's people/team pickers are two
// independent fetches: `getRoutingRules` no longer folds `getOrgPeopleAndTeams`
// into its own return, because `getSettingsHub` (the settings-hub aggregator)
// also calls `getRoutingRules` and only ever reads `.totals` off it. Folding
// the picker fetch in there fired an extra, unused `/users/get-teams-and-users/`
// request on every settings-hub load. Fetched in parallel here instead, since
// this is the one place both are actually needed.
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
return getRoutingRules({ cookies });
const [data, options] = await Promise.all([
getRoutingRules({ cookies }),
getOrgPeopleAndTeams(cookies)
]);
return { ...data, ...options };
}
/**
* Everything both the create and edit form submit. `is_active` is
* deliberately not read here: only the create form carries that checkbox
* (the edit form's own row control, Turn off / Turn on, owns that state), so
* reading `form.get('is_active')` here would answer `false` on every edit
* submit, since an absent checkbox and an unchecked one look identical to
* `FormData`. The `create` action below attaches `is_active` itself, straight
* from the one form that renders it.
*
* The condition rows are read by `readConditionRows` in `routing.js`, not
* here: the form submits one indexed name per row (`condition_field_0` and so
* on) and the reader pairs them by index, so a control that submits nothing
* cannot shift its neighbours. Keeping it in the module is what makes that
* rule testable, since the harness cannot import a route file.
*
* @param {FormData} form
*/
function readValues(form) {
return {
name: form.get('name')?.toString().trim() ?? '',
priority_order: form.get('priority_order')?.toString() ?? '0',
stop_processing: form.get('stop_processing') === 'true',
strategy: form.get('strategy')?.toString() ?? '',
target_assignee_ids: form.getAll('target_assignee_ids').map((v) => v.toString()),
target_team_id: form.get('target_team_id')?.toString() ?? '',
conditions: readConditionRows(form)
};
}
/** @type {import('./$types').Actions} */
export const actions = {
// Admin-only server-side (`RoutingRuleListCreateView.post` calls
// `_is_admin` first). `can_edit` only hides the control.
async create(event) {
const form = await event.request.formData();
const values = { ...readValues(form), is_active: form.get('is_active') === 'true' };
try {
await createRoutingRule(event, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { create: { error: 'Only an admin can add routing rules.' } });
}
return fail(400, { create: { error: readableError(err, 'Could not add the rule.') } });
}
return { created: true };
},
async update(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
const values = readValues(form);
try {
await updateRoutingRule(event, id, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { update: { error: 'Only an admin can change routing rules.' } });
}
return fail(400, { update: { error: readableError(err, 'Could not save the rule.') } });
}
return { updated: true };
},
async deactivate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await updateRoutingRule(event, id, { is_active: false });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { deactivate: { error: 'Only an admin can turn routing rules off.' } });
}
return fail(400, {
deactivate: { error: readableError(err, 'Could not turn the rule off.') }
});
}
return { deactivated: true };
},
// A rule's own action, not a reuse of `update`. `readValues` builds a full
// rule payload from the form, and this control only ever submits an id: if
// it posted through `update`, `name` would read back as `''` (`FormData`
// has no key for it) and `createRoutingRule`/`updateRoutingRule`'s own
// `buildBody` would forward that, blanking the name on every reactivation.
// Calling `updateRoutingRule` directly with only `{ is_active: true }`
// sidesteps `readValues` entirely, so the PUT body is `{ is_active: true }`
// and nothing else.
async activate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await updateRoutingRule(event, id, { is_active: true });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { activate: { error: 'Only an admin can turn routing rules on.' } });
}
return fail(400, {
activate: { error: readableError(err, 'Could not turn the rule on.') }
});
}
return { activated: true };
},
// `RoutingRuleDetailView.delete` is a hard delete, unlike `/custom-fields/`.
// The row control that posts here says so before it does.
async remove(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await deleteRoutingRule(event, id);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { remove: { error: 'Only an admin can delete routing rules.' } });
}
return fail(400, { remove: { error: readableError(err, 'Could not delete the rule.') } });
}
return { removed: true };
}
};

View file

@ -14,23 +14,96 @@
* unconditional, stop-processing rule appears, everything below it is
* dead, and "matched 0 times" on its own looks like a quiet month.
*/
import { enhance } from '$app/forms';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import SettingsCrumb from '$lib/v2/components/SettingsCrumb.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import StatCard from '$lib/v2/components/StatCard.svelte';
import SettingsFormPanel from '$lib/v2/components/SettingsFormPanel.svelte';
import ConfirmAction from '$lib/v2/components/ConfirmAction.svelte';
import { count } from '$lib/v2/format.js';
import {
ROUTING_STRATEGY_LABEL,
ROUTING_STRATEGY_NAME,
CONDITION_FIELD_LABEL,
CONDITION_OP_LABEL
} from '$lib/v2/enums.js';
import { missingOptions, inactiveOptionLabel } from '$lib/v2/pickers.js';
import { Plus, GripVertical, TriangleAlert, UserX } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
// `null` when the panel is closed, `'new'` when adding, or the rule object
// when editing that row. One panel, two modes, so two rows can never be
// open for edit at once.
let editing = $state(/** @type {any} */ (null));
// The strategy currently selected in the form, so the target picker below
// it (team vs. people) can switch without a round trip. Seeded from the
// rule being edited.
let strategy = $state('direct');
// The repeating condition rows being edited, as `{ field, op, value }`.
// Held here, rather than read off `editing`, so a row can be added or
// removed before submitting.
let conditionRows = $state(
/** @type {{ field: string, op: string, value: string }[]} */ ([])
);
function openCreate() {
editing = 'new';
strategy = 'direct';
conditionRows = [];
}
function openEdit(r) {
editing = r;
strategy = r.strategy;
// Joining an array value into a comma-separated string is the right
// editing representation for a single text input, and it round-trips: on
// save, `routing.js`'s `cleanConditions` splits an `in` row's value back
// on commas. That round trip is lossless for any value made up of
// entries with no comma in them, which is the whole reason this editor
// can stay one text input instead of its own repeating sub-list. Known
// limitation: a value that itself contains a literal comma cannot be
// expressed here, since splitting can't tell that comma apart from a
// separator.
conditionRows = r.conditions.map((c) => ({
field: c.field,
op: c.op,
value: Array.isArray(c.value) ? c.value.join(', ') : String(c.value ?? '')
}));
}
let totals = $derived(data.totals);
// Targets the picker cannot offer, because the profile was deactivated
// after it was chosen. `getOrgPeopleAndTeams` only ever returns active
// profiles, while the rule's M2M keeps them, which is exactly what the
// deactivated-target flag on the card below is about. Without an option of
// their own the multi-select submits nothing for them, so an edit made for
// another reason would quietly rewrite the rotation.
let missingAssignees = $derived(
editing && editing !== 'new' ? missingOptions(data.people, editing.target_assignees) : []
);
/**
* A stored condition field the select cannot show.
*
* `custom_fields.<key>` is a first-class field everywhere else: the backend
* accepts it, `cleanConditions` accepts it, and `when()` below renders it.
* `CONDITION_FIELD_LABEL` only carries the six fixed ones, so without this
* the select would have no matching option, would submit nothing, and the
* row would be dropped or, worse, shift its neighbours.
*/
function unlistedField(field) {
if (!field || field in CONDITION_FIELD_LABEL) return null;
return field.startsWith('custom_fields.')
? `Custom field: ${field.slice('custom_fields.'.length)}`
: field;
}
/**
* Reachability, walked in evaluation order.
*
@ -84,7 +157,9 @@
<span class="v2-num">{count(totals.active)}</span> active rules, run in this order until one matches
{/snippet}
{#snippet actions()}
<button class="v2-btn v2-btn-primary"><Plus />New rule</button>
{#if data.can_edit && !editing}
<button class="v2-btn v2-btn-primary" onclick={openCreate}><Plus />New rule</button>
{/if}
{/snippet}
</PageHeader>
@ -103,6 +178,231 @@
<div class="v2-scroll">
<div class="v2-pad" style="padding-bottom:32px">
{#if editing}
<SettingsFormPanel
title={editing === 'new' ? 'New rule' : `Edit ${editing.name}`}
action={editing === 'new' ? '?/create' : '?/update'}
error={editing === 'new' ? form?.create?.error : form?.update?.error}
submitLabel={editing === 'new' ? 'Add rule' : 'Save rule'}
oncancel={() => (editing = null)}
ondone={() => (editing = null)}
>
{#snippet fields()}
{#if editing !== 'new'}
<input type="hidden" name="id" value={editing.id} />
{/if}
<div class="v2-field">
<label for="r-name">Name</label>
<input
id="r-name"
class="v2-input"
name="name"
maxlength="128"
required
value={editing === 'new' ? '' : editing.name}
/>
</div>
<div class="v2-field">
<label for="r-priority">Priority</label>
<input
id="r-priority"
class="v2-input"
type="number"
name="priority_order"
min="0"
value={editing === 'new' ? 0 : editing.priority_order}
/>
<p class="v2-hint">The engine runs rules low number first and takes the first match.</p>
</div>
<div class="v2-field">
<label for="r-strategy">Then</label>
<select id="r-strategy" class="v2-input" name="strategy" bind:value={strategy}>
{#each Object.entries(ROUTING_STRATEGY_NAME) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
</div>
{#if strategy === 'by_team'}
<div class="v2-field">
<label for="r-team">Team</label>
<select id="r-team" class="v2-input" name="target_team_id" required>
<option value="" disabled selected={editing === 'new' || !editing.target_team}>
Choose a team
</option>
{#each data.teams as t (t.id)}
<option
value={t.id}
selected={editing !== 'new' && editing.target_team?.id === t.id}
>
{t.name}
</option>
{/each}
</select>
{#if !data.teams.length}
<p class="v2-hint">No teams in this org yet.</p>
{/if}
</div>
{:else}
<div class="v2-field">
<label for="r-people">Who</label>
<select
id="r-people"
class="v2-input"
name="target_assignee_ids"
multiple
style="height:118px"
>
<!-- A target whose profile has been deactivated is not in
`data.people`, so it gets an option of its own. Dropping
it would be a change to who tickets go to, made by an
edit that was about something else. -->
{#each missingAssignees as a (a.id)}
<option value={a.id} selected>{inactiveOptionLabel(a.name)}</option>
{/each}
{#each data.people as p (p.id)}
<option
value={p.id}
selected={editing !== 'new' &&
editing.target_assignees.some((a) => a.id === p.id)}
>
{p.name}
</option>
{/each}
</select>
<p class="v2-hint">
{#if strategy === 'direct'}
Direct uses only the first person selected here; the rest are ignored unless the
strategy changes.
{:else}
Round robin and least busy cycle through everyone listed here.
{/if}
</p>
{#if missingAssignees.length}
<p class="v2-hint">
{missingAssignees.map((a) => a.name).join(', ')}
{missingAssignees.length === 1 ? 'is' : 'are'} deactivated and still in the rotation.
Deselect to take them out.
</p>
{/if}
</div>
{/if}
<div class="v2-field">
<label for="r-stop">Stop processing</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400">
<input
id="r-stop"
type="checkbox"
name="stop_processing"
value="true"
checked={editing !== 'new' && editing.stop_processing}
/>
A matching rule with this on stops the engine, so later rules never see the ticket.
</label>
</div>
{#if editing === 'new'}
<div class="v2-field">
<label for="r-active">Active</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400">
<input id="r-active" type="checkbox" name="is_active" value="true" checked />
Starts matching tickets as soon as it is saved.
</label>
</div>
{/if}
<div class="v2-field v2-sfp-wide">
<label for="r-cond-0">Conditions</label>
<!-- One indexed name per row, not three parallel `condition_field`
/ `condition_op` / `condition_value` lists. A select with no
matching option submits no entry at all, so parallel lists
paired by position silently shifted every row after the gap.
Indexed names make a row's three inputs travel together, and
`readConditionRows` in `routing.js` pairs them by index. -->
{#each conditionRows as row, i (i)}
{@const unlisted = unlistedField(row.field)}
<div style="margin-bottom:6px">
<div style="display:flex;gap:7px;align-items:center">
<select
id={i === 0 ? 'r-cond-0' : undefined}
class="v2-input"
name="condition_field_{i}"
bind:value={row.field}
>
<option value="">Choose a field</option>
{#if unlisted}
<!-- A stored `custom_fields.<key>` condition. The label
map carries only the six fixed fields, so without
this option the select would match nothing and the
condition would not survive an unrelated edit. -->
<option value={row.field}>{unlisted}</option>
{/if}
{#each Object.entries(CONDITION_FIELD_LABEL) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
<select class="v2-input" name="condition_op_{i}" bind:value={row.op}>
{#each Object.entries(CONDITION_OP_LABEL) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
<input
class="v2-input"
name="condition_value_{i}"
bind:value={row.value}
placeholder="Value"
/>
<button
class="v2-btn v2-btn-sm"
type="button"
onclick={() => (conditionRows = conditionRows.filter((_, j) => j !== i))}
>
Remove
</button>
</div>
{#if row.op === 'in'}
<!-- Only for `in`: `eq`, `contains` and `regex` all take the
value as one plain string, so this hint would be wrong
for them. -->
<p class="v2-hint">
Comma separated. Matches if any one of these values matches.
</p>
{/if}
</div>
{/each}
<button
class="v2-btn v2-btn-sm"
type="button"
style="align-self:flex-start"
onclick={() => (conditionRows = [...conditionRows, { field: '', op: 'eq', value: '' }])}
>
Add a condition
</button>
{#if !conditionRows.some((row) => row.field)}
<p class="v2-error" style="margin-top:8px">
<TriangleAlert size={13} style="flex:none;margin-top:1px" />
<span>With no conditions this rule matches every ticket.</span>
</p>
{/if}
</div>
{/snippet}
</SettingsFormPanel>
{/if}
{#if form?.deactivate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.deactivate.error}</p>
{/if}
{#if form?.activate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.activate.error}</p>
{/if}
{#if form?.remove?.error}
<p class="v2-error" style="margin-bottom:12px">{form.remove.error}</p>
{/if}
<div class="v2-label" style="margin-bottom:10px">In evaluation order</div>
<div style="display:flex;flex-direction:column;gap:9px">
@ -172,6 +472,35 @@
<span class="v2-sub" style="font-size:10.5px;margin-top:4px">falls through</span>
{/if}
</div>
{#if data.can_edit}
<div class="v2-rule-actions">
<button class="v2-btn v2-btn-sm" type="button" onclick={() => openEdit(r)}>
Edit
</button>
{#if r.is_active}
<ConfirmAction
action="?/deactivate"
label="Turn off"
confirmLabel="Turn off"
explain="Stops matching new tickets. It stays in the list, off, until turned back on."
hidden={{ id: r.id }}
/>
{:else}
<form method="POST" action="?/activate" use:enhance>
<input type="hidden" name="id" value={r.id} />
<button class="v2-btn v2-btn-sm" type="submit">Turn on</button>
</form>
{/if}
<ConfirmAction
action="?/remove"
label="Delete"
confirmLabel="Delete"
explain="Deleted permanently. Tickets already routed by it stay where they are."
hidden={{ id: r.id }}
/>
</div>
{/if}
</div>
{/each}
</div>
@ -206,6 +535,12 @@
align-items: flex-end;
text-align: right;
}
.v2-rule-actions {
flex: none;
display: flex;
gap: 6px;
align-items: center;
}
.v2-rule-flag {
display: flex;
gap: 7px;
@ -230,5 +565,17 @@
margin-top: 10px;
padding-left: 30px;
}
/* Same reflow as `.v2-rule-stat` above: a flex:none column becomes its
own full-width row, indented to align under the card's main content
rather than the grip handle. `flex-wrap` is added here (unlike the
stat block) because an armed `ConfirmAction` grows into a button pair
plus a sentence of explain text, which does not fit one line at
390px. */
.v2-rule-actions {
flex-wrap: wrap;
width: 100%;
margin-top: 10px;
padding-left: 30px;
}
}
</style>

View file

@ -1,6 +1,37 @@
import { getTags } from '$lib/server/v2/tags.js';
import { fail } from '@sveltejs/kit';
import { getTags, createTag } from '$lib/server/v2/tags.js';
import { readableError } from '$lib/server/v2/form-errors.js';
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
return getTags({ cookies });
}
/** @type {import('./$types').Actions} */
export const actions = {
// Admin-only. `load`'s `can_edit` hides the "New tag" control for a member,
// but that is only the affordance; this check is the one that matters.
// `TagsListView.post` (`backend/common/views/tags_views.py:143-149`) 403s a
// non-admin regardless of what reaches it, so this branch exists to turn
// that response into a message instead of a raw backend error string, not
// to be the authority itself.
async create(event) {
const form = await event.request.formData();
const name = form.get('name')?.toString() ?? '';
try {
await createTag(event, { name });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { create: { name, error: 'Only an admin can create tags.' } });
}
return fail(400, {
create: { name, error: readableError(err, 'Could not create the tag.') }
});
}
// No redirect. The list is on this page, and `load` re-runs after an
// action, so the new tag appears where the user is already looking.
return { created: true };
}
};

View file

@ -10,6 +10,7 @@
* collide on the slug, but "Renewal" and "Renewals" slug differently while
* meaning the same thing, and the work splits silently across them.
*/
import { enhance } from '$app/forms';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import SettingsCrumb from '$lib/v2/components/SettingsCrumb.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
@ -18,8 +19,20 @@
import { count } from '$lib/v2/format.js';
import { Plus, Merge, Tags as TagsIcon } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
// The "New tag" disclosure. Open while adding, closed on a successful
// create; a failed one stays open so the error next to the input is
// actually visible instead of vanishing the instant the form action
// returns.
let adding = $state(false);
// Disables the Create button while the submit is in flight, so a
// double-click cannot fire two creates: the second would either duplicate
// the tag or, for a same-named resubmit, come back as "already exists"
// right after the first one succeeded.
let busy = $state(false);
let totals = $derived(data.totals);
@ -62,7 +75,48 @@
<span class="v2-num">{count(totals.active)}</span> in use across accounts, leads, deals and tickets
{/snippet}
{#snippet actions()}
<button class="v2-btn v2-btn-primary"><Plus />New tag</button>
{#if data.can_edit}
{#if adding}
<form
class="v2-tag-add-form"
method="POST"
action="?/create"
use:enhance={() => {
busy = true;
return async (/** @type {any} */ { result, update }) => {
await update();
busy = false;
// Only close on success. A failed submit (the empty-name guard, a
// duplicate name) has to leave the form open, or the error rendered
// below never gets seen: it lives inside this same `{#if adding}`
// block.
if (result.type === 'success') adding = false;
};
}}
>
{#if form?.create?.error}
<p class="v2-error">{form.create.error}</p>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
class="v2-input"
name="name"
placeholder="Tag name"
required
autofocus
disabled={busy}
/>
<button class="v2-btn v2-btn-primary" type="submit" disabled={busy}>Create</button>
<button class="v2-btn" type="button" disabled={busy} onclick={() => (adding = false)}>
Cancel
</button>
</form>
{:else}
<button class="v2-btn v2-btn-primary" onclick={() => (adding = true)}
><Plus />New tag</button
>
{/if}
{/if}
{/snippet}
</PageHeader>
@ -93,6 +147,8 @@
one of them misses the other.
</p>
</div>
<!-- Merge stays unwired: /api/tags/<id>/merge/ does not resolve, there
is no backing endpoint yet. Tracked in the phase 2 plan. -->
<button class="v2-btn v2-btn-sm" style="flex:none;align-self:center">Merge</button>
</div>
{/each}
@ -170,4 +226,49 @@
border-radius: var(--v2-radius);
background: var(--v2-card);
}
/* The inline "New tag" disclosure lives in the page header's actions row
(a flex container), alongside the plain Create/Cancel buttons, so it has
to lay out as a single line rather than stack like a full-page form. */
.v2-tag-add-form {
display: flex;
align-items: center;
gap: 7px;
position: relative;
}
.v2-tag-add-form .v2-input {
width: 200px;
}
/* The error sits above the input per the leads/new convention, but there is
no room to grow the header's height for it, so it floats instead of
pushing the row down. `right: 0` plus `white-space: normal` (rather than
the `nowrap` a one-line floating label would default to) let it wrap
within the row's own width instead of running off narrow viewports; a
duplicate-name rejection ("A tag with this name already exists.") is
long enough to hit this in practice. */
.v2-tag-add-form .v2-error {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin: 0 0 4px;
white-space: normal;
}
/* Below 768px `.v2-header .v2-actions` goes full width (v2.css), but this
form's own children do not: a 200px input plus two buttons is wider than
a phone screen, and a flex item does not shrink past its content's fixed
width on its own. Confirmed by emulating a 320px viewport: the row
overflowed and clipped "Cancel" before this rule existed. Wrapping the
input onto its own full-width line, with the buttons below it, keeps
everything on screen and touchable instead of cut off. */
@media (max-width: 768px) {
.v2-tag-add-form {
flex-wrap: wrap;
width: 100%;
}
.v2-tag-add-form .v2-input {
width: 100%;
flex: 1 1 100%;
}
}
</style>

View file

@ -1,6 +1,146 @@
import { getApprovalRules } from '$lib/server/v2/ticket-approvals.js';
import { fail } from '@sveltejs/kit';
import {
getApprovalRules,
createApprovalRule,
updateApprovalRule,
deleteApprovalRule
} from '$lib/server/v2/ticket-approvals.js';
import { getOrgPeopleAndTeams } from '$lib/server/v2/org-people.js';
import { readableError } from '$lib/server/v2/form-errors.js';
// The rule list and the create/edit form's approver picker are two independent
// fetches: `getApprovalRules` does not fold `getOrgPeopleAndTeams` into its own
// return, because `getSettingsHub` (the settings-hub aggregator) also calls
// `getApprovalRules` and only ever reads `.totals` off it. Folding the picker
// fetch in there would fire an extra, unused `/users/get-teams-and-users/`
// request on every settings-hub load. Fetched in parallel here instead, since
// this is the one place both are actually needed.
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
return getApprovalRules({ cookies });
const [data, options] = await Promise.all([
getApprovalRules({ cookies }),
getOrgPeopleAndTeams(cookies)
]);
return { ...data, ...options };
}
/**
* Everything both the create and edit form submit. `is_active` is
* deliberately not read here: only the create form carries that checkbox
* (the edit form's own row control, Turn off / Turn on, owns that state), so
* reading `form.get('is_active')` here would answer `false` on every edit
* submit, since an absent checkbox and an unchecked one look identical to
* `FormData`. The `create` action below attaches `is_active` itself, straight
* from the one form that renders it. `trigger_event` is never read from the
* form at all: `ticket-approvals.js` sets it, since `pre_close` is the only
* value the backend accepts.
*
* @param {FormData} form
*/
function readValues(form) {
return {
name: form.get('name')?.toString().trim() ?? '',
match_priority: form.get('match_priority')?.toString() ?? '',
match_case_type: form.get('match_case_type')?.toString() ?? '',
match_team_id: form.get('match_team_id')?.toString() ?? '',
approver_role: form.get('approver_role')?.toString() ?? '',
approver_ids: form.getAll('approver_ids').map((v) => v.toString())
};
}
/** @type {import('./$types').Actions} */
export const actions = {
// Admin-only server-side (`ApprovalRuleListCreateView.post` calls
// `_is_admin` first). `can_edit` only hides the control.
async create(event) {
const form = await event.request.formData();
const values = { ...readValues(form), is_active: form.get('is_active') === 'true' };
try {
await createApprovalRule(event, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { create: { error: 'Only an admin can change approval rules.' } });
}
return fail(400, { create: { error: readableError(err, 'Could not add the rule.') } });
}
return { created: true };
},
async update(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
const values = readValues(form);
try {
await updateApprovalRule(event, id, values);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { update: { error: 'Only an admin can change approval rules.' } });
}
return fail(400, { update: { error: readableError(err, 'Could not save the rule.') } });
}
return { updated: true };
},
async deactivate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await updateApprovalRule(event, id, { is_active: false });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { deactivate: { error: 'Only an admin can change approval rules.' } });
}
return fail(400, {
deactivate: { error: readableError(err, 'Could not turn the rule off.') }
});
}
return { deactivated: true };
},
// A rule's own action, not a reuse of `update`. `readValues` builds a full
// rule payload from the form, and this control only ever submits an id: if
// it posted through `update`, `name` would read back as `''` (`FormData`
// has no key for it) and `createApprovalRule`/`updateApprovalRule`'s own
// `buildBody` would forward that, blanking the name on every reactivation.
// Calling `updateApprovalRule` directly with only `{ is_active: true }`
// sidesteps `readValues` entirely, so the PUT body is `{ is_active: true }`
// and nothing else.
async activate(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
try {
await updateApprovalRule(event, id, { is_active: true });
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { activate: { error: 'Only an admin can change approval rules.' } });
}
return fail(400, {
activate: { error: readableError(err, 'Could not turn the rule on.') }
});
}
return { activated: true };
},
// `ApprovalRuleDetailView.delete` has two outcomes behind the one verb: a
// rule with no approval history is destroyed, and a rule with any history
// (in any state, not just pending) is turned off instead, because
// `Approval.rule` is `on_delete=PROTECT`. Both answer 2xx, so which one
// happened is read off the response body by `deleteApprovalRule` and
// reported here rather than assumed. The page renders a line saying the
// rule was turned off when that is what happened, so an admin is never told
// "deleted" about a row that is still in the list.
async remove(event) {
const form = await event.request.formData();
const id = form.get('id')?.toString() ?? '';
let outcome;
try {
outcome = await deleteApprovalRule(event, id);
} catch (/** @type {any} */ err) {
if (err?.status === 403) {
return fail(403, { remove: { error: 'Only an admin can change approval rules.' } });
}
return fail(400, { remove: { error: readableError(err, 'Could not delete the rule.') } });
}
return { removed: !outcome.turned_off, remove: { turned_off: outcome.turned_off } };
}
};

View file

@ -18,18 +18,59 @@
* flagged "any admin, including the requester" as a hole; the view has since
* closed it, so the warning is gone.
*/
import { enhance } from '$app/forms';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import SettingsCrumb from '$lib/v2/components/SettingsCrumb.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import SettingsFormPanel from '$lib/v2/components/SettingsFormPanel.svelte';
import ConfirmAction from '$lib/v2/components/ConfirmAction.svelte';
import { count } from '$lib/v2/format.js';
import { ROLE_LABEL } from '$lib/v2/enums.js';
import { missingOptions, inactiveOptionLabel } from '$lib/v2/pickers.js';
import { Plus, TriangleAlert, ChevronRight } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
// cases.approvals: PRIORITY_CHOICE and CASE_TYPE. Not in `$lib/v2/enums.js`
// because there is no shared label map to derive from: value and label are
// the same string for both, and no other v2 page reads either list.
const MATCH_PRIORITIES = ['Low', 'Normal', 'High', 'Urgent'];
const MATCH_CASE_TYPES = ['Question', 'Incident', 'Problem'];
// `ROLE_LABEL` covers `Profile.role` (ADMIN/USER), not the approver-role
// vocabulary this form offers (ADMIN/MANAGER). MANAGER is not a real
// `Profile.role` value (see the module docstring), so it has no entry in
// that map and would render as `undefined`. Labelled here instead.
function approverRoleLabel(role) {
return role === 'MANAGER' ? 'Manager' : (ROLE_LABEL[role] ?? role);
}
// `null` when the panel is closed, `'new'` when adding, or the rule object
// when editing that row. One panel, two modes, so two rows can never be
// open for edit at once.
let editing = $state(/** @type {any} */ (null));
function openCreate() {
editing = 'new';
}
function openEdit(r) {
editing = r;
}
let totals = $derived(data.totals);
let rules = $derived(data.rules);
// Named approvers the picker cannot offer, because the profile has been
// deactivated since it was named. Without an option of their own the
// multi-select submits nothing for them and an unrelated edit drops them,
// which can widen a rule to "any admin" or, on a MANAGER rule, leave it
// clearable by nobody.
let missingApprovers = $derived(
editing && editing !== 'new' ? missingOptions(data.people, editing.approvers) : []
);
/** MANAGER matches no Profile.role, so an empty approvers list means nobody. */
const clearableByNobody = (r) =>
r.is_active && r.approver_role === 'MANAGER' && !r.approvers.length;
@ -52,12 +93,172 @@
<span class="v2-num">{count(totals.pending)}</span> approvals waiting on them right now
{/snippet}
{#snippet actions()}
<button class="v2-btn v2-btn-primary"><Plus />New rule</button>
{#if data.can_edit && !editing}
<button class="v2-btn v2-btn-primary" onclick={openCreate}><Plus />New rule</button>
{/if}
{/snippet}
</PageHeader>
<div class="v2-scroll">
<div class="v2-pad" style="padding-top:18px;padding-bottom:32px">
{#if editing}
<SettingsFormPanel
title={editing === 'new' ? 'New rule' : `Edit ${editing.name}`}
action={editing === 'new' ? '?/create' : '?/update'}
error={editing === 'new' ? form?.create?.error : form?.update?.error}
submitLabel={editing === 'new' ? 'Add rule' : 'Save rule'}
oncancel={() => (editing = null)}
ondone={() => (editing = null)}
>
{#snippet fields()}
{#if editing !== 'new'}
<input type="hidden" name="id" value={editing.id} />
{/if}
<div class="v2-field">
<label for="a-name">Name</label>
<input
id="a-name"
class="v2-input"
name="name"
maxlength="128"
required
value={editing === 'new' ? '' : editing.name}
/>
</div>
<div class="v2-field">
<label for="a-role">Approver role</label>
<select id="a-role" class="v2-input" name="approver_role">
{#each ['ADMIN', 'MANAGER'] as role (role)}
<option
value={role}
selected={editing === 'new' ? role === 'ADMIN' : editing.approver_role === role}
>
{approverRoleLabel(role)}
</option>
{/each}
</select>
</div>
<div class="v2-field v2-sfp-wide">
<label for="a-approvers">Named approvers</label>
<select
id="a-approvers"
class="v2-input"
name="approver_ids"
multiple
style="height:96px"
>
<!-- A named approver whose profile is no longer active is not in
`data.people`, so it gets an option of its own here. Without
one the browser submits nothing for it and saving an
unrelated field would drop the approver silently. -->
{#each missingApprovers as a (a.id)}
<option value={a.id} selected>{inactiveOptionLabel(a.email)}</option>
{/each}
{#each data.people as p (p.id)}
<option
value={p.id}
selected={editing !== 'new' && editing.approvers.some((a) => a.id === p.id)}
>
{p.name}
</option>
{/each}
</select>
<p class="v2-hint">
Named approvers are in addition to the role above. Leave this empty and anyone with
that role can clear the approval.
</p>
{#if missingApprovers.length}
<p class="v2-hint">
{missingApprovers.length === 1
? 'One approver is'
: `${missingApprovers.length} approvers are`}
no longer active. They stay named until you deselect them, and they cannot clear an approval
while their account is off.
</p>
{/if}
</div>
<div class="v2-field">
<label for="a-priority">Priority</label>
<select id="a-priority" class="v2-input" name="match_priority">
<option value="" selected={editing === 'new' || !editing.match_priority}>Any</option>
{#each MATCH_PRIORITIES as p (p)}
<option value={p} selected={editing !== 'new' && editing.match_priority === p}>
{p}
</option>
{/each}
</select>
</div>
<div class="v2-field">
<label for="a-type">Ticket type</label>
<select id="a-type" class="v2-input" name="match_case_type">
<option value="" selected={editing === 'new' || !editing.match_case_type}>
Any
</option>
{#each MATCH_CASE_TYPES as t (t)}
<option value={t} selected={editing !== 'new' && editing.match_case_type === t}>
{t}
</option>
{/each}
</select>
</div>
<div class="v2-field">
<label for="a-team">Team</label>
<select id="a-team" class="v2-input" name="match_team_id">
<option value="" selected={editing === 'new' || !editing.match_team}>Any team</option>
{#each data.teams as t (t.id)}
<option value={t.id} selected={editing !== 'new' && editing.match_team?.id === t.id}>
{t.name}
</option>
{/each}
</select>
{#if !data.teams.length}
<p class="v2-hint">No teams in this org yet.</p>
{/if}
</div>
{#if editing === 'new'}
<div class="v2-field">
<label for="a-active">Active</label>
<label style="display:flex;gap:8px;align-items:center;font-weight:400">
<input id="a-active" type="checkbox" name="is_active" value="true" checked />
Starts gating matching ticket closes as soon as it is saved.
</label>
</div>
{/if}
{/snippet}
</SettingsFormPanel>
{/if}
{#if form?.deactivate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.deactivate.error}</p>
{/if}
{#if form?.activate?.error}
<p class="v2-error" style="margin-bottom:12px">{form.activate.error}</p>
{/if}
{#if form?.remove?.error}
<p class="v2-error" style="margin-bottom:12px">{form.remove.error}</p>
{/if}
{#if form?.remove?.turned_off}
<!-- What actually happened, not what the button said. The backend
soft-disables a rule that has approval history rather than
destroying it, so the row is still in the list below, off, and
saying nothing here would read as a delete that failed silently. -->
<div class="v2-rule-flag" style="margin-bottom:12px">
<TriangleAlert size={14} style="color:var(--v2-clay);flex:none" />
<span>
That rule had approval history, so it was turned off instead of deleted. The approvals it
already gated have to keep pointing at it. It is still in the list below, marked Off, and
gates nothing.
</span>
</div>
{/if}
<div class="v2-label" style="margin-bottom:10px">Rules</div>
<div style="display:flex;flex-direction:column;gap:9px">
{#each rules as r (r.id)}
@ -76,7 +277,7 @@
<b style="font-weight:600;color:var(--v2-ink)">→</b>
cleared by
{#if r.approvers.length}
{r.approvers.join(' or ')}
{r.approvers.map((a) => a.email).join(' or ')}
{:else}
any {r.approver_role.toLowerCase()}
{/if}
@ -106,6 +307,45 @@
</a>
{/if}
</div>
{#if data.can_edit}
<div style="display:flex;gap:6px;align-items:center;flex:none">
<button class="v2-btn v2-btn-sm" type="button" onclick={() => openEdit(r)}>
Edit
</button>
{#if r.is_active}
<ConfirmAction
action="?/deactivate"
label="Turn off"
confirmLabel="Turn off"
explain="Stops gating new ticket closes. It stays in the list, off, until turned back on."
hidden={{ id: r.id }}
/>
{:else}
<form method="POST" action="?/activate" use:enhance>
<input type="hidden" name="id" value={r.id} />
<button class="v2-btn v2-btn-sm" type="submit">Turn on</button>
</form>
{/if}
<!-- Not "deleted permanently". The backend destroys a rule
only when it has never been used; one with any approval
history, in any state, is turned off instead, because the
approval rows have to keep pointing at it. `pending_count`
cannot predict which happens: it counts only the pending
state, and the backend's check counts every state. So the
line names both outcomes, and the page reports which one
actually happened afterwards. -->
<ConfirmAction
action="?/remove"
label="Delete"
confirmLabel="Delete"
explain={r.pending_count > 0
? `${r.pending_count} approvals are waiting on this rule. A rule that has ever gated a close is turned off rather than deleted, because the record has to be kept.`
: 'A rule that has never gated a close is deleted for good. One with any approval history is turned off instead, because the record has to be kept.'}
hidden={{ id: r.id }}
/>
</div>
{/if}
</div>
</div>
{/each}

View file

@ -1,4 +1,6 @@
import { getTimesheet } from '$lib/server/v2/timesheet.js';
import { fail } from '@sveltejs/kit';
import { getTimesheet, stopTimer } from '$lib/server/v2/timesheet.js';
import { readableError } from '$lib/server/v2/form-errors.js';
/**
* `?start=&end=` (YYYY-MM-DD) pick the week; absent, it defaults to this ISO
@ -11,3 +13,19 @@ export async function load({ cookies, url }) {
const end = url.searchParams.get('end') || undefined;
return await getTimesheet({ cookies }, { start, end });
}
/** @type {import('./$types').Actions} */
export const actions = {
async stop(event) {
const form = await event.request.formData();
const entryId = form.get('entry_id')?.toString() ?? '';
try {
await stopTimer(event, entryId);
} catch (/** @type {any} */ err) {
return fail(400, { error: readableError(err, 'Could not stop the timer.') });
}
return { stopped: true };
}
};

View file

@ -12,17 +12,31 @@
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import PageHeader from '$lib/v2/components/PageHeader.svelte';
import StatCard from '$lib/v2/components/StatCard.svelte';
import Pill from '$lib/v2/components/Pill.svelte';
import { money, count, shortDate } from '$lib/v2/format.js';
import { ChevronLeft, ChevronRight, Square, Receipt } from '@lucide/svelte';
/** @type {{ data: any }} */
let { data } = $props();
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
let week = $derived(data.week);
// Disables every "Stop timer" button while a stop is in flight. Without
// this, a double-click fires two POSTs; the first stops the timer and the
// second gets a 400 "Timer is already stopped." back, so a successful stop
// reads as a failure.
let busy = $state(false);
const stopping = () => {
busy = true;
return async (/** @type {any} */ { update }) => {
await update();
busy = false;
};
};
/** Minutes elapsed since this page loaded, added to the server's figure. */
let sinceLoad = $state(0);
onMount(() => {
@ -141,17 +155,36 @@
<!-- The one thing on this page that changes while you look at it. -->
<div class="v2-next" style="margin-bottom:16px">
<div class="v2-next-body">
<div class="v2-label" style="color:var(--v2-ember)">Timer running</div>
<div class="v2-label" style="color:var(--v2-ember)">
{week.running_count === 1 ? 'Timer running' : `${week.running_count} timers running`}
</div>
{#if form?.error}
<!-- Stop can fail (ownership check, network). Silent failure here
would repeat the exact bug this page exists to fix: a timer
that keeps accruing time with nothing on screen saying so. -->
<p class="v2-error">{form.error}</p>
{/if}
<div class="v2-next-text">
{#each week.days as d (d.date)}
{#each d.entries.filter((e) => e.is_running) as e (e.id)}
<span class="v2-num">{hm(liveMinutes(e))}</span> on
<a href="/tickets/{e.case.id}" style="color:inherit">{e.case.name}</a>
<div class="v2-running-row">
<span>
<span class="v2-num">{hm(liveMinutes(e))}</span> on
<a href="/tickets/{e.case.id}" style="color:inherit">{e.case.name}</a>
</span>
<!-- One form per entry. A single shared button could not say
which of several running timers it meant. -->
<form method="POST" action="?/stop" use:enhance={stopping}>
<input type="hidden" name="entry_id" value={e.id} />
<button class="v2-btn v2-btn-primary" type="submit" disabled={busy}>
<Square size={13} />Stop timer
</button>
</form>
</div>
{/each}
{/each}
</div>
</div>
<button class="v2-btn v2-btn-primary"><Square size={13} />Stop timer</button>
</div>
{/if}
@ -220,3 +253,12 @@
</p>
</div>
</div>
<style>
.v2-running-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
</style>

53
frontend/vitest.config.js Normal file
View file

@ -0,0 +1,53 @@
import { defineConfig } from 'vitest/config';
import path from 'node:path';
/**
* THE CEILING OF THIS HARNESS
*
* This is a bare Vite config, not the app's own `vite.config.js`, so none of
* SvelteKit's virtual modules exist here by default. Two aliases are wired in
* below because `$lib/server/v2/leads.js:26` and `$lib/api-helpers.js:8` both
* import `$env/dynamic/public` at module scope: it is a Vite virtual module
* generated at build/dev time, not a real file, so a plain `node`/vitest run
* cannot resolve it without a stand-in. `$lib` is aliased because
* `path.resolve('./src/lib')` is the only way to reach it outside SvelteKit's
* own resolution.
*
* That is as far as this config goes. It resolves `$lib` and
* `$env/dynamic/public` and nothing else: `$app/forms`, `$app/navigation`,
* `$env/static/*`, and `$env/dynamic/private` are all still unresolvable
* here, and there is no Svelte plugin, so `.svelte` files cannot be compiled
* at all. `include: ['src/**\/*.test.js']` will happily pick up a test placed
* anywhere under `src/`, including next to a route module or a component, but
* importing either from a test run through this config fails with an opaque
* "Failed to resolve import" rather than a clear "not supported here".
*
* This harness therefore covers server modules only (`$lib/server/v2/*.js`
* and similar, functions over `apiRequest` with no SvelteKit runtime
* dependency). Testing a route module (`+page.server.js`, which pulls in
* `$app/forms` via its actions) or a `.svelte` component means moving
* `test: {}` into the app's own `vite.config.js`, where the `sveltekit()`
* plugin already provides `$lib`, `$app/*`, `$env/*` and Svelte compilation
* for free. That is a real tradeoff, not a strict improvement: it also pulls
* `sentrySvelteKit()` and `tailwindcss()` into every test run, which this
* standalone config exists specifically to avoid.
*
* `path.resolve('./src/lib')` below resolves against `process.cwd()`, not
* against this file's location, so `pnpm test` (and `vitest` directly) must
* be run from `frontend/`. Running it from the repo root or from `src/`
* silently resolves `$lib` to the wrong place.
*/
export default defineConfig({
test: {
// Server modules only. They are plain functions over `apiRequest`, so they
// need no DOM, and a node environment keeps the run fast.
environment: 'node',
include: ['src/**/*.test.js']
},
resolve: {
alias: {
$lib: path.resolve('./src/lib'),
'$env/dynamic/public': path.resolve('./test/stubs/env-dynamic-public.js')
}
}
});

View file

@ -10,7 +10,7 @@ class ApiConfig {
// ==========================================================================
/// Development API URL
static const String _developmentUrl = 'https://msi-8000.rcdev.in';
static const String _developmentUrl = 'https://pc-8000.rcdev.in';
/// Production API URL
static const String _productionUrl = 'https://api.bottlecrm.io';