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:
parent
24390478e0
commit
86e13796fe
61 changed files with 7392 additions and 502 deletions
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
104
frontend/src/lib/server/v2/business-hours.test.js
Normal file
104
frontend/src/lib/server/v2/business-hours.test.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const apiRequest = vi.fn();
|
||||
vi.mock('$lib/api-helpers.js', () => ({ apiRequest: (...a) => apiRequest(...a) }));
|
||||
|
||||
const { updateBusinessHours, addHoliday, removeHoliday } =
|
||||
await import('$lib/server/v2/business-hours.js');
|
||||
|
||||
const event = /** @type {any} */ ({ cookies: { get: () => 'token' } });
|
||||
|
||||
const week = [
|
||||
{ day: 'Monday', open: '09:00', close: '17:00' },
|
||||
{ day: 'Tuesday', open: '09:00', close: '17:00' },
|
||||
{ day: 'Wednesday', open: '09:00', close: '17:00' },
|
||||
{ day: 'Thursday', open: '09:00', close: '17:00' },
|
||||
{ day: 'Friday', open: '09:00', close: '17:00' },
|
||||
{ day: 'Saturday', open: null, close: null },
|
||||
{ day: 'Sunday', open: null, close: null }
|
||||
];
|
||||
|
||||
describe('updateBusinessHours', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
it('unfolds the days array back into the fourteen flat fields', async () => {
|
||||
apiRequest.mockResolvedValue({});
|
||||
|
||||
await updateBusinessHours(event, 'cal1', week, { name: 'Support', timezone: 'Asia/Kolkata' });
|
||||
|
||||
const [endpoint, options] = apiRequest.mock.calls[0];
|
||||
// The detail URL, not the collection path the read uses.
|
||||
expect(endpoint).toBe('/business-hours/calendar/cal1/');
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(options.body.monday_open).toBe('09:00');
|
||||
expect(options.body.friday_close).toBe('17:00');
|
||||
// A closed day is both fields null, never one of them.
|
||||
expect(options.body.saturday_open).toBeNull();
|
||||
expect(options.body.saturday_close).toBeNull();
|
||||
expect(options.body.name).toBe('Support');
|
||||
expect(options.body.timezone).toBe('Asia/Kolkata');
|
||||
});
|
||||
|
||||
it('never sends is_default, which the serializer marks read-only', async () => {
|
||||
apiRequest.mockResolvedValue({});
|
||||
await updateBusinessHours(event, 'cal1', week, {
|
||||
name: 'Support',
|
||||
timezone: 'UTC',
|
||||
is_default: false
|
||||
});
|
||||
expect(apiRequest.mock.calls[0][1].body.is_default).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a day with an open but no close, naming the day', async () => {
|
||||
const broken = week.map((d) => (d.day === 'Wednesday' ? { ...d, close: null } : d));
|
||||
await expect(
|
||||
updateBusinessHours(event, 'cal1', broken, { name: 'S', timezone: 'UTC' })
|
||||
).rejects.toThrow(/Wednesday/);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an empty calendar id before making a request', async () => {
|
||||
await expect(
|
||||
updateBusinessHours(event, '', week, { name: 'S', timezone: 'UTC' })
|
||||
).rejects.toThrow(/calendar/i);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addHoliday', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
it('POSTs date and name to the calendar sub-resource', async () => {
|
||||
apiRequest.mockResolvedValue({ id: 'h1', date: '2026-12-25', name: 'Christmas' });
|
||||
|
||||
await addHoliday(event, 'cal1', { date: '2026-12-25', name: 'Christmas' });
|
||||
|
||||
const [endpoint, options] = apiRequest.mock.calls[0];
|
||||
expect(endpoint).toBe('/business-hours/calendar/cal1/holidays/');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toEqual({ date: '2026-12-25', name: 'Christmas' });
|
||||
});
|
||||
|
||||
it('rejects a missing date before making a request', async () => {
|
||||
await expect(addHoliday(event, 'cal1', { date: '', name: 'X' })).rejects.toThrow(/date/i);
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeHoliday', () => {
|
||||
beforeEach(() => {
|
||||
apiRequest.mockReset();
|
||||
});
|
||||
|
||||
it('DELETEs the holiday under its calendar', async () => {
|
||||
apiRequest.mockResolvedValue(null);
|
||||
await removeHoliday(event, 'cal1', 'h1');
|
||||
const [endpoint, options] = apiRequest.mock.calls[0];
|
||||
expect(endpoint).toBe('/business-hours/calendar/cal1/holidays/h1/');
|
||||
expect(options.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
174
frontend/src/lib/server/v2/custom-fields.test.js
Normal file
174
frontend/src/lib/server/v2/custom-fields.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
190
frontend/src/lib/server/v2/escalation.test.js
Normal file
190
frontend/src/lib/server/v2/escalation.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
197
frontend/src/lib/server/v2/inbound-email.test.js
Normal file
197
frontend/src/lib/server/v2/inbound-email.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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' }
|
||||
};
|
||||
}
|
||||
|
|
|
|||
56
frontend/src/lib/server/v2/leads.test.js
Normal file
56
frontend/src/lib/server/v2/leads.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
233
frontend/src/lib/server/v2/macros.test.js
Normal file
233
frontend/src/lib/server/v2/macros.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
54
frontend/src/lib/server/v2/org-people.js
Normal file
54
frontend/src/lib/server/v2/org-people.js
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
62
frontend/src/lib/server/v2/org-people.test.js
Normal file
62
frontend/src/lib/server/v2/org-people.test.js
Normal 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: [] });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
78
frontend/src/lib/server/v2/reopen.test.js
Normal file
78
frontend/src/lib/server/v2/reopen.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
537
frontend/src/lib/server/v2/routing.test.js
Normal file
537
frontend/src/lib/server/v2/routing.test.js
Normal 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'] }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
56
frontend/src/lib/server/v2/tags.test.js
Normal file
56
frontend/src/lib/server/v2/tags.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
200
frontend/src/lib/server/v2/ticket-approvals.test.js
Normal file
200
frontend/src/lib/server/v2/ticket-approvals.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
34
frontend/src/lib/server/v2/timesheet.test.js
Normal file
34
frontend/src/lib/server/v2/timesheet.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/v2/components/ConfirmAction.svelte
Normal file
74
frontend/src/lib/v2/components/ConfirmAction.svelte
Normal 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>
|
||||
112
frontend/src/lib/v2/components/SettingsFormPanel.svelte
Normal file
112
frontend/src/lib/v2/components/SettingsFormPanel.svelte
Normal 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>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
66
frontend/src/lib/v2/pickers.js
Normal file
66
frontend/src/lib/v2/pickers.js
Normal 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)`;
|
||||
}
|
||||
73
frontend/src/lib/v2/pickers.test.js
Normal file
73
frontend/src/lib/v2/pickers.test.js
Normal 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)');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
32
frontend/src/routes/(app)/leads/new/+page.server.js
Normal file
32
frontend/src/routes/(app)/leads/new/+page.server.js
Normal 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');
|
||||
}
|
||||
};
|
||||
324
frontend/src/routes/(app)/leads/new/+page.svelte
Normal file
324
frontend/src/routes/(app)/leads/new/+page.svelte
Normal 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>
|
||||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
53
frontend/vitest.config.js
Normal 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')
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue