Add per-user closed statuses for the feed and ding
SDP closes a resolved ticket on its own after 2 or 3 days, so watching only Closed gave the ding days late. Each user can now pick which statuses count as closed for the feed (feedStatuses, defaulting to STATUS_NAMES). With Resolved picked, tickets are timed by resolved_time so the automatic close later doesn't ding again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
48c206fd53
commit
80a5e2c965
@ -29,7 +29,7 @@ Copy `src/env.example` to a file named `.env` and fill it in. The region, timing
|
|||||||
| `PUBLIC_URL` | Yes when hosted | The address people open, with no trailing slash, e.g. `https://dashboard.example.com`. Zoho sends people back to `PUBLIC_URL/auth/callback`. Defaults to `http://localhost:3000`. Cookies are marked secure when it starts with `https://`. |
|
| `PUBLIC_URL` | Yes when hosted | The address people open, with no trailing slash, e.g. `https://dashboard.example.com`. Zoho sends people back to `PUBLIC_URL/auth/callback`. Defaults to `http://localhost:3000`. Cookies are marked secure when it starts with `https://`. |
|
||||||
| `SESSION_DAYS` | No | How many days a sign-in lasts before Zoho is asked again. Defaults to 7. |
|
| `SESSION_DAYS` | No | How many days a sign-in lasts before Zoho is asked again. Defaults to 7. |
|
||||||
| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. |
|
| `TECHNICIAN_NAMES` | No | Comma-separated technician names, spelled as they appear in SDP. One table row per name. Leave it empty to include every technician. |
|
||||||
| `STATUS_NAMES` | No | Statuses that count as "closed" for the ding and the Closed today count. Everything else counts as open. Defaults to `Closed,Resolved,Canceled`. |
|
| `STATUS_NAMES` | No | Statuses that count as "closed" for the Closed today count and the status table. Everything else counts as open. Also the default for which statuses put a ticket in the closed feed and ding, which each person can change in Settings. Defaults to `Closed,Resolved,Canceled`. |
|
||||||
| `SITE_NAMES` | No | Only include requests from these sites (the region). Leave it empty for all sites. |
|
| `SITE_NAMES` | No | Only include requests from these sites (the region). Leave it empty for all sites. |
|
||||||
| `GROUP_NAMES` | No | Only include requests in these support groups, e.g. `Customer Support`. Leave it empty for all groups. |
|
| `GROUP_NAMES` | No | Only include requests in these support groups, e.g. `Customer Support`. Leave it empty for all groups. |
|
||||||
| `UNASSIGNED_EXCLUDED_STATUSES` | No | Statuses the Unassigned requests counter leaves out. Defaults to `STATUS_NAMES`. |
|
| `UNASSIGNED_EXCLUDED_STATUSES` | No | Statuses the Unassigned requests counter leaves out. Defaults to `STATUS_NAMES`. |
|
||||||
@ -132,9 +132,10 @@ The full security model, its limits and the open items are in [docs/SECURITY.md]
|
|||||||
- **Sites, groups and technicians** to watch. Empty means everyone. Suggestions come from names seen on recent tickets anywhere in SDP (refreshed at most hourly and shared by everyone). Any name can be typed, but it must match SDP's spelling; a name that differs only in case is saved with SDP's spelling.
|
- **Sites, groups and technicians** to watch. Empty means everyone. Suggestions come from names seen on recent tickets anywhere in SDP (refreshed at most hourly and shared by everyone). Any name can be typed, but it must match SDP's spelling; a name that differs only in case is saved with SDP's spelling.
|
||||||
- **Timing:** how often the status table recounts (2 to 60 minutes) and how long closed tickets stay in the feed (1 to 24 hours). The closed check itself always runs every minute.
|
- **Timing:** how often the status table recounts (2 to 60 minutes) and how long closed tickets stay in the feed (1 to 24 hours). The closed check itself always runs every minute.
|
||||||
- **Weather location**, from the list of campuses in `src/places.js`.
|
- **Weather location**, from the list of campuses in `src/places.js`.
|
||||||
|
- **Which statuses count as closed for the feed and ding.** Starts as `STATUS_NAMES`. SDP closes a resolved ticket on its own after a few days, so ticking **Resolved** gives the alert when the ticket is resolved instead. With Resolved ticked, a ticket's time in the feed is its resolve time, so the automatic close later doesn't ding again.
|
||||||
- **What's shown:** the stat tiles, weather card and closed feed can be hidden (the ding still plays with the feed hidden), and so can individual status columns.
|
- **What's shown:** the stat tiles, weather card and closed feed can be hidden (the ding still plays with the feed hidden), and so can individual status columns.
|
||||||
|
|
||||||
Settings are saved to the person's Zoho account on the server, so they follow them to any browser. Only the values that differ from `.env` are stored, so someone who never changed the region picks up later changes to `.env`. **Reset to defaults** goes back to `.env`. The closed and open status rules aren't per person; they stay in `.env`.
|
Settings are saved to the person's Zoho account on the server, so they follow them to any browser. Only the values that differ from `.env` are stored, so someone who never changed the region picks up later changes to `.env`. **Reset to defaults** goes back to `.env`. The closed and open status rules for the status table, Closed today and the unassigned count aren't per person; they stay in `.env`.
|
||||||
|
|
||||||
The browser runs the checks, using the signed-in user's token, so every open dashboard makes its own SDP calls. That's one small query a minute and a recount at the person's chosen interval (`WORKLOAD_MINUTES` by default). On first load it fetches the feed's full length (12 hours by default) of closes.
|
The browser runs the checks, using the signed-in user's token, so every open dashboard makes its own SDP calls. That's one small query a minute and a recount at the person's chosen interval (`WORKLOAD_MINUTES` by default). On first load it fetches the feed's full length (12 hours by default) of closes.
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
|
|||||||
- **2026-10-15:** **Name suggestions without a new scope.** `/api/names` runs one unscoped query (last 30 days, up to 500 tickets, names only) with the caller's token, at most hourly for everyone, and merges into `DATA_DIR/names.json` so the list grows over time. Typed names are still accepted; a name matching a suggestion except for case is saved with SDP's spelling, because the technician filters compare exactly. Proper pick lists from SDP's setup/users APIs would need another scope and a re-sign-in for everyone.
|
- **2026-10-15:** **Name suggestions without a new scope.** `/api/names` runs one unscoped query (last 30 days, up to 500 tickets, names only) with the caller's token, at most hourly for everyone, and merges into `DATA_DIR/names.json` so the list grows over time. Typed names are still accepted; a name matching a suggestion except for case is saved with SDP's spelling, because the technician filters compare exactly. Proper pick lists from SDP's setup/users APIs would need another scope and a re-sign-in for everyone.
|
||||||
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
|
- **2026-10-15:** **Weather locations are a fixed list** (`src/places.js`), not free zip codes, so a browser can't make the server look up arbitrary places and the per-zip cache stays small. The `.env` zip is added to the list if it's missing.
|
||||||
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
|
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
|
||||||
|
- **2026-10-19:** **The closed feed's statuses are per user (`feedStatuses`, v2.1.1).** This narrows the decision above: `STATUS_NAMES` still defines open and closed for the status table, Closed today and the unassigned count, and is the default for `feedStatuses`. Our SDP closes a resolved ticket on its own after 2 or 3 days if it isn't reopened, so watching only Closed gave the ding days late. When Resolved is in a user's list, the feed times a ticket by `resolved_time` before `completed_time`, so the automatic close (new `completed_time`, old `resolved_time`) falls outside the check window and doesn't ding again. A reopen and re-resolve gives a new `resolved_time`, which counts as a fresh close. An empty list is refused, because the feed would then take any status. Before this, an empty `STATUS_NAMES` made the feed take any status; now it falls back to `Closed,Resolved,Canceled` like the rest.
|
||||||
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
|
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
|
||||||
- **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 had this gap until v2.1.0 was deployed on 2026-10-16. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope.
|
- **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 had this gap until v2.1.0 was deployed on 2026-10-16. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope.
|
||||||
- **2026-10-16:** **No ticket subjects, anywhere (FERPA).** Subjects can name students or describe their situation. The closed feed and the unassigned query no longer put `subject` in `fields_required`, and the objects the server returns are built field by field, so a subject SDP sends anyway (if it ever ignores `fields_required`) is dropped before it leaves the server. Feed cards show the ticket number, technician and closing time. Don't add `subject`, `description` or any other free-text field to a query or response without a new review; to identify a ticket, use its number and open it in SDP.
|
- **2026-10-16:** **No ticket subjects, anywhere (FERPA).** Subjects can name students or describe their situation. The closed feed and the unassigned query no longer put `subject` in `fields_required`, and the objects the server returns are built field by field, so a subject SDP sends anyway (if it ever ignores `fields_required`) is dropped before it leaves the server. Feed cards show the ticket number, technician and closing time. Don't add `subject`, `description` or any other free-text field to a query or response without a new review; to identify a ticket, use its number and open it in SDP.
|
||||||
|
|||||||
@ -10,7 +10,7 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
|
|||||||
- **Identity and data:** each user signs in with their own Zoho account (web OAuth, Server-based Application client), and every SDP query runs with that user's token. What a user sees is limited by their own SDP permissions and then by the region filter.
|
- **Identity and data:** each user signs in with their own Zoho account (web OAuth, Server-based Application client), and every SDP query runs with that user's token. What a user sees is limited by their own SDP permissions and then by the region filter.
|
||||||
- **Sessions:** the Zoho tokens live in an encrypted, `HttpOnly` cookie. The server stores no sessions, so restarts and upgrades don't sign anyone out. Its only state is saved settings and name suggestions.
|
- **Sessions:** the Zoho tokens live in an encrypted, `HttpOnly` cookie. The server stores no sessions, so restarts and upgrades don't sign anyone out. Its only state is saved settings and name suggestions.
|
||||||
- **Polling:** the browser runs the checks (closed feed every minute, status table at the user's interval) and keeps the closed feed. The server answers each call with the caller's token and their saved settings. Weather (per location) and name suggestions are shared.
|
- **Polling:** the browser runs the checks (closed feed every minute, status table at the user's interval) and keeps the closed feed. The server answers each call with the caller's token and their saved settings. Weather (per location) and name suggestions are shared.
|
||||||
- **Region and settings:** `.env` sets the defaults (`SITE_NAMES`, `GROUP_NAMES`, `TECHNICIAN_NAMES`, `WORKLOAD_MINUTES`, `OWM_ZIP_CODE`). Each user can override sites, groups, technicians, timing, weather location and hidden panels/columns in Settings; these are saved on the server by Zoho email in `DATA_DIR` (a Docker volume). The status lists stay in `.env` for everyone.
|
- **Region and settings:** `.env` sets the defaults (`SITE_NAMES`, `GROUP_NAMES`, `TECHNICIAN_NAMES`, `WORKLOAD_MINUTES`, `OWM_ZIP_CODE`). Each user can override sites, groups, technicians, timing, weather location and hidden panels/columns in Settings; these are saved on the server by Zoho email in `DATA_DIR` (a Docker volume). The status lists stay in `.env` for everyone, except which statuses the closed feed and ding watch, which each user can change.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
@ -36,3 +36,4 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
|
|||||||
- Docker image `v0idf1sh/sdp-dashboard`, an example `docker-compose.yml`, and a Gitea Actions build on `v*-prod*` tags.
|
- Docker image `v0idf1sh/sdp-dashboard`, an example `docker-compose.yml`, and a Gitea Actions build on `v*-prod*` tags.
|
||||||
- Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM.
|
- Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM.
|
||||||
- Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers.
|
- Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers.
|
||||||
|
- Per-user closed statuses for the feed and ding (2026-10-19, `v2.1.1`), so Resolved can ding without waiting days for SDP's automatic close. Tested with stubs only; check against real SDP that resolved tickets carry `resolved_time` and that the later automatic close doesn't ding again.
|
||||||
|
|||||||
@ -1181,6 +1181,13 @@ function renderSettings() {
|
|||||||
for (const n of d.hiddenStatuses) if (!lower(statusNames).includes(n.toLowerCase())) statusNames.push(n);
|
for (const n of d.hiddenStatuses) if (!lower(statusNames).includes(n.toLowerCase())) statusNames.push(n);
|
||||||
const hiddenStatuses = new Set(lower(d.hiddenStatuses));
|
const hiddenStatuses = new Set(lower(d.hiddenStatuses));
|
||||||
|
|
||||||
|
// the help desk's done statuses first, then the open ones (Resolved may be counted as open)
|
||||||
|
const feedChoices = [];
|
||||||
|
for (const n of [...(data.closedStatuses || []), ...d.feedStatuses, ...statusNames]) {
|
||||||
|
if (!lower(feedChoices).includes(n.toLowerCase())) feedChoices.push(n);
|
||||||
|
}
|
||||||
|
const feedStatuses = new Set(lower(d.feedStatuses));
|
||||||
|
|
||||||
const body = !data.canSaveSettings
|
const body = !data.canSaveSettings
|
||||||
? `<p class="settings-error">Zoho didn't share your email at sign-in, so settings can't be saved. Sign out and in again.</p>`
|
? `<p class="settings-error">Zoho didn't share your email at sign-in, so settings can't be saved. Sign out and in again.</p>`
|
||||||
: `<div class="settings-body">
|
: `<div class="settings-body">
|
||||||
@ -1219,6 +1226,11 @@ function renderSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="hint">The ding for newly closed tickets plays even with the feed hidden.</div>
|
<div class="hint">The ding for newly closed tickets plays even with the feed hidden.</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Count as closed for the feed and ding</legend>
|
||||||
|
<div class="checks">${feedChoices.map(n => `<label><input type="checkbox" data-feed-status="${esc(n)}"${feedStatuses.has(n.toLowerCase()) ? ' checked' : ''}>${esc(n)}</label>`).join('')}</div>
|
||||||
|
<div class="hint">Tick Resolved to hear about tickets when they're resolved, not days later when they close on their own. They won't ding again when they close.</div>
|
||||||
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Status columns</legend>
|
<legend>Status columns</legend>
|
||||||
${statusNames.length
|
${statusNames.length
|
||||||
@ -1291,6 +1303,10 @@ $('settings').addEventListener('change', e => {
|
|||||||
draft.hiddenStatuses = draft.hiddenStatuses.filter(n => n.toLowerCase() !== t.dataset.status.toLowerCase());
|
draft.hiddenStatuses = draft.hiddenStatuses.filter(n => n.toLowerCase() !== t.dataset.status.toLowerCase());
|
||||||
if (!t.checked) draft.hiddenStatuses.push(t.dataset.status);
|
if (!t.checked) draft.hiddenStatuses.push(t.dataset.status);
|
||||||
}
|
}
|
||||||
|
if (t.dataset.feedStatus) {
|
||||||
|
draft.feedStatuses = draft.feedStatuses.filter(n => n.toLowerCase() !== t.dataset.feedStatus.toLowerCase());
|
||||||
|
if (t.checked) draft.feedStatuses.push(t.dataset.feedStatus);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('settings').addEventListener('click', e => {
|
$('settings').addEventListener('click', e => {
|
||||||
@ -1316,8 +1332,8 @@ async function saveSettings(btn, body) {
|
|||||||
const input = $('add-' + field);
|
const input = $('add-' + field);
|
||||||
if (input && input.value.trim()) addName(field, input);
|
if (input && input.value.trim()) addName(field, input);
|
||||||
}
|
}
|
||||||
const { sites, groups, technicians, workloadMinutes, feedHours, weatherZip, hiddenStatuses, hiddenPanels } = draft;
|
const { sites, groups, technicians, workloadMinutes, feedHours, weatherZip, hiddenStatuses, hiddenPanels, feedStatuses } = draft;
|
||||||
body = { sites, groups, technicians, workloadMinutes, feedHours, hiddenStatuses, hiddenPanels };
|
body = { sites, groups, technicians, workloadMinutes, feedHours, hiddenStatuses, hiddenPanels, feedStatuses };
|
||||||
if (data.places.length && weatherZip) body.weatherZip = weatherZip;
|
if (data.places.length && weatherZip) body.weatherZip = weatherZip;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1340,10 +1356,10 @@ function applySettings(reply) {
|
|||||||
Object.assign(data, reply);
|
Object.assign(data, reply);
|
||||||
const after = data.settings;
|
const after = data.settings;
|
||||||
const changed = (...fields) => fields.some(f => JSON.stringify(before[f]) !== JSON.stringify(after[f]));
|
const changed = (...fields) => fields.some(f => JSON.stringify(before[f]) !== JSON.stringify(after[f]));
|
||||||
if (changed('sites', 'groups', 'technicians', 'feedHours')) generation++;
|
if (changed('sites', 'groups', 'technicians', 'feedHours', 'feedStatuses')) generation++;
|
||||||
|
|
||||||
if (changed('sites', 'groups', 'technicians', 'feedHours')) {
|
if (changed('sites', 'groups', 'technicians', 'feedHours', 'feedStatuses')) {
|
||||||
// a different region or feed length: start the feed over, quietly, like a fresh page load
|
// a different region, feed length or closed statuses: start the feed over, quietly, like a fresh page load
|
||||||
feed.clear();
|
feed.clear();
|
||||||
data.tickets = [];
|
data.tickets = [];
|
||||||
seen = new Set();
|
seen = new Set();
|
||||||
|
|||||||
@ -90,6 +90,8 @@ function createApiRoutes({ sessions, weatherService, settings, timing, userSetti
|
|||||||
sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000,
|
sessionEndsAt: req.session.startedAt + timing.sessionDays * 24 * 60 * 60 * 1000,
|
||||||
...settingsReply(req.session.user),
|
...settingsReply(req.session.user),
|
||||||
settingsLimits: { ...LIMITS, panels: PANELS },
|
settingsLimits: { ...LIMITS, panels: PANELS },
|
||||||
|
// the help desk's done statuses, offered as choices for feedStatuses
|
||||||
|
closedStatuses: settings.closedStatuses,
|
||||||
places: weatherService.enabled ? places : [],
|
places: weatherService.enabled ? places : [],
|
||||||
checkMinutes: timing.checkMinutes,
|
checkMinutes: timing.checkMinutes,
|
||||||
windowMinutes: timing.windowMinutes
|
windowMinutes: timing.windowMinutes
|
||||||
@ -154,7 +156,7 @@ function createApiRoutes({ sessions, weatherService, settings, timing, userSetti
|
|||||||
state,
|
state,
|
||||||
tickets,
|
tickets,
|
||||||
technicians: prefs.technicians,
|
technicians: prefs.technicians,
|
||||||
statusSet: settings.statusSet,
|
statusSet: new Set(prefs.feedStatuses.map(s => s.toLowerCase())),
|
||||||
scope: { sites: prefs.sites, groups: prefs.groups },
|
scope: { sites: prefs.sites, groups: prefs.groups },
|
||||||
windowMinutes: minutes
|
windowMinutes: minutes
|
||||||
});
|
});
|
||||||
|
|||||||
@ -38,9 +38,8 @@ const statuses = toList(STATUS_NAMES);
|
|||||||
// statuses that count as done; everything else shows up in the workload table
|
// statuses that count as done; everything else shows up in the workload table
|
||||||
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
|
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
|
||||||
|
|
||||||
// the status rules every user shares; these aren't in the settings panel
|
// the status rules every user shares; these aren't in the settings panel (the feed's statuses are, see feedStatuses)
|
||||||
const settings = {
|
const settings = {
|
||||||
statusSet: new Set(statuses.map(s => s.toLowerCase())),
|
|
||||||
closedStatuses,
|
closedStatuses,
|
||||||
// statuses the unassigned counter ignores, to match the ManageEngine view
|
// statuses the unassigned counter ignores, to match the ManageEngine view
|
||||||
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
|
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
|
||||||
@ -90,6 +89,9 @@ const defaults = {
|
|||||||
feedHours: 12, // how long a closed ticket stays in the feed
|
feedHours: 12, // how long a closed ticket stays in the feed
|
||||||
weatherZip: OWM_ZIP_CODE || null,
|
weatherZip: OWM_ZIP_CODE || null,
|
||||||
hiddenStatuses: [], // status columns left out of the table
|
hiddenStatuses: [], // status columns left out of the table
|
||||||
|
// statuses that put a ticket in the closed feed and ding; some people want Resolved,
|
||||||
|
// which comes days before SDP closes a ticket on its own
|
||||||
|
feedStatuses: closedStatuses,
|
||||||
hiddenPanels: [] // see PANELS in settings.js
|
hiddenPanels: [] // see PANELS in settings.js
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const LIMITS = {
|
|||||||
groups: 50,
|
groups: 50,
|
||||||
technicians: 100,
|
technicians: 100,
|
||||||
hiddenStatuses: 50,
|
hiddenStatuses: 50,
|
||||||
|
feedStatuses: 20,
|
||||||
nameLength: 100,
|
nameLength: 100,
|
||||||
workloadMinutes: { min: 2, max: 60 },
|
workloadMinutes: { min: 2, max: 60 },
|
||||||
feedHours: { min: 1, max: 24 }
|
feedHours: { min: 1, max: 24 }
|
||||||
@ -18,7 +19,7 @@ const LIMITS = {
|
|||||||
// running when the feed is hidden
|
// running when the feed is hidden
|
||||||
const PANELS = ['stats', 'weather', 'feed'];
|
const PANELS = ['stats', 'weather', 'feed'];
|
||||||
|
|
||||||
const LIST_FIELDS = ['sites', 'groups', 'technicians', 'hiddenStatuses'];
|
const LIST_FIELDS = ['sites', 'groups', 'technicians', 'hiddenStatuses', 'feedStatuses'];
|
||||||
|
|
||||||
class SettingsError extends Error {}
|
class SettingsError extends Error {}
|
||||||
|
|
||||||
@ -65,6 +66,8 @@ function createUserSettings({ dataDir, defaults, places }) {
|
|||||||
for (const field of LIST_FIELDS) {
|
for (const field of LIST_FIELDS) {
|
||||||
if (input[field] !== undefined) clean[field] = cleanList(input[field], field);
|
if (input[field] !== undefined) clean[field] = cleanList(input[field], field);
|
||||||
}
|
}
|
||||||
|
// an empty list would make the feed take any status, so a ticket moved to "In Progress" would ding
|
||||||
|
if (clean.feedStatuses && !clean.feedStatuses.length) throw new SettingsError('Pick at least one status that counts as closed');
|
||||||
for (const field of ['workloadMinutes', 'feedHours']) {
|
for (const field of ['workloadMinutes', 'feedHours']) {
|
||||||
if (input[field] !== undefined) clean[field] = cleanNumber(input[field], field);
|
if (input[field] !== undefined) clean[field] = cleanNumber(input[field], field);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,11 +11,18 @@ function toMillis(completedTime) {
|
|||||||
return Number.isFinite(ms) ? ms : null;
|
return Number.isFinite(ms) ? ms : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRelevantTicketTime(ticket) {
|
// the time field that says when a ticket was done. With preferResolved, a resolved ticket counts
|
||||||
return toMillis(ticket.completed_time)
|
// from its resolve, so the automatic close a few days later doesn't bring it back as a fresh close.
|
||||||
|| toMillis(ticket.resolved_time)
|
function doneField(ticket, preferResolved) {
|
||||||
|| toMillis(ticket.last_updated_time)
|
const order = preferResolved
|
||||||
|| null;
|
? ['resolved_time', 'completed_time', 'last_updated_time']
|
||||||
|
: ['completed_time', 'resolved_time', 'last_updated_time'];
|
||||||
|
return order.find(field => toMillis(ticket[field])) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelevantTicketTime(ticket, preferResolved = false) {
|
||||||
|
const field = doneField(ticket, preferResolved);
|
||||||
|
return field ? toMillis(ticket[field]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// the user's token was rejected; let the route sign them out instead of showing it as a data error
|
// the user's token was rejected; let the route sign them out instead of showing it as a data error
|
||||||
@ -134,6 +141,7 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
|
|||||||
}, 'closed feed');
|
}, 'closed feed');
|
||||||
|
|
||||||
const found = data.requests;
|
const found = data.requests;
|
||||||
|
const preferResolved = statusSet.has('resolved');
|
||||||
|
|
||||||
for (const ticket of found) {
|
for (const ticket of found) {
|
||||||
if (!inScope(ticket, scope)) continue;
|
if (!inScope(ticket, scope)) continue;
|
||||||
@ -145,7 +153,8 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
|
|||||||
|| statusSet.has(statusInternalName.toLowerCase());
|
|| statusSet.has(statusInternalName.toLowerCase());
|
||||||
if (!statusMatches) continue;
|
if (!statusMatches) continue;
|
||||||
|
|
||||||
const closedAt = getRelevantTicketTime(ticket);
|
const closedField = doneField(ticket, preferResolved);
|
||||||
|
const closedAt = closedField ? toMillis(ticket[closedField]) : null;
|
||||||
if (!closedAt || closedAt <= windowStart) continue;
|
if (!closedAt || closedAt <= windowStart) continue;
|
||||||
|
|
||||||
const technician = ticket.technician ? ticket.technician.name : null;
|
const technician = ticket.technician ? ticket.technician.name : null;
|
||||||
@ -157,10 +166,7 @@ async function fetchClosedTickets({ accessToken, state, tickets, technicians, st
|
|||||||
technician,
|
technician,
|
||||||
status: statusName || null,
|
status: statusName || null,
|
||||||
closedAt,
|
closedAt,
|
||||||
closedDisplay: ticket.completed_time?.display_value
|
closedDisplay: ticket[closedField]?.display_value || null
|
||||||
|| ticket.resolved_time?.display_value
|
|
||||||
|| ticket.last_updated_time?.display_value
|
|
||||||
|| null
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user