@escapenavigator/utils 2.0.32 → 2.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/classify-api-error.js +17 -0
- package/dist/classify-api-error.spec.js +26 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/is-foreign-error.d.ts +44 -0
- package/dist/is-foreign-error.js +55 -0
- package/dist/is-foreign-error.spec.d.ts +1 -0
- package/dist/is-foreign-error.spec.js +81 -0
- package/dist/resolve-calendar-location-id.d.ts +22 -0
- package/dist/resolve-calendar-location-id.js +24 -0
- package/dist/resolve-calendar-location-id.spec.d.ts +1 -0
- package/dist/resolve-calendar-location-id.spec.js +43 -0
- package/dist/role-comment-permissions.d.ts +7 -0
- package/dist/role-comment-permissions.js +9 -0
- package/dist/role-comment-permissions.spec.d.ts +1 -0
- package/dist/role-comment-permissions.spec.js +16 -0
- package/dist/serialize-slot.js +2 -0
- package/dist/user-session-permissions.d.ts +29 -0
- package/dist/user-session-permissions.js +37 -1
- package/dist/user-session-permissions.spec.d.ts +1 -0
- package/dist/user-session-permissions.spec.js +94 -0
- package/package.json +3 -3
|
@@ -63,6 +63,20 @@ const SKIP_HTTP_STATUSES = new Set([304]);
|
|
|
63
63
|
* bearer, неожиданный 403 на embed).
|
|
64
64
|
*/
|
|
65
65
|
const SKIP_AUTH_STATUSES_ON_APPS = new Set(['app', 'auth']);
|
|
66
|
+
/**
|
|
67
|
+
* Ручки, где 404 — это ответ пользователю, а не сбой.
|
|
68
|
+
*
|
|
69
|
+
* `POST /users/login` отдаёт 404 «User not found or incorrect password
|
|
70
|
+
* entered.» на любой неверный email или пароль, то есть на обычную опечатку.
|
|
71
|
+
* `login-switch` — то же самое при попытке войти в чужой аккаунт.
|
|
72
|
+
*
|
|
73
|
+
* Такие 404 неотличимы от нормальной работы формы входа и ничего не говорят о
|
|
74
|
+
* здоровье сервиса: ESCAPE-NAVIGATOR-AUTH-3 набрал 394 события от 247 человек
|
|
75
|
+
* за два месяца, и ни одно не было багом. Пропажу самого роута это не скроет —
|
|
76
|
+
* тогда бы логин не работал ни у кого, что видно и по 404 на соседних ручках,
|
|
77
|
+
* и по резкому падению входов.
|
|
78
|
+
*/
|
|
79
|
+
const SKIP_NOT_FOUND_API_KEYS = new Set(['login', 'loginSwitch']);
|
|
66
80
|
const isNetworkErrorMessage = (message) => typeof message === 'string' && /network error/i.test(message);
|
|
67
81
|
function classifyApiError(ctx) {
|
|
68
82
|
const { status, code, apiKey, errorCode, originalErrorCode, message, app } = ctx;
|
|
@@ -87,6 +101,9 @@ function classifyApiError(ctx) {
|
|
|
87
101
|
(!app || SKIP_AUTH_STATUSES_ON_APPS.has(app))) {
|
|
88
102
|
return { severity: 'skip', level: 'info', fingerprint: null, shouldReport: false };
|
|
89
103
|
}
|
|
104
|
+
if (status === 404 && SKIP_NOT_FOUND_API_KEYS.has(apiKey)) {
|
|
105
|
+
return { severity: 'skip', level: 'info', fingerprint: null, shouldReport: false };
|
|
106
|
+
}
|
|
90
107
|
// 5xx → critical. Включаем status в fingerprint, чтобы 502/503/504 не
|
|
91
108
|
// схлопывались в один issue с 500 — у них разные источники проблем
|
|
92
109
|
// (proxy/CDN vs само приложение vs gateway-timeout).
|
|
@@ -28,6 +28,32 @@ describe('classifyApiError', () => {
|
|
|
28
28
|
expect(r.shouldReport).toBe(true);
|
|
29
29
|
expect(r.severity).toBe('debug');
|
|
30
30
|
});
|
|
31
|
+
it('404 на login → skip (неверный email или пароль, а не сбой)', () => {
|
|
32
|
+
const r = (0, classify_api_error_1.classifyApiError)({
|
|
33
|
+
apiKey: 'login',
|
|
34
|
+
method: 'POST',
|
|
35
|
+
url: '/users/login',
|
|
36
|
+
status: 404,
|
|
37
|
+
app: 'auth',
|
|
38
|
+
});
|
|
39
|
+
expect(r.severity).toBe('skip');
|
|
40
|
+
expect(r.shouldReport).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
it('404 на остальных ручках → НЕ skip', () => {
|
|
43
|
+
const r = (0, classify_api_error_1.classifyApiError)({ ...base, status: 404, app: 'auth' });
|
|
44
|
+
expect(r.shouldReport).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('5xx на login → НЕ skip (это уже настоящий сбой входа)', () => {
|
|
47
|
+
const r = (0, classify_api_error_1.classifyApiError)({
|
|
48
|
+
apiKey: 'login',
|
|
49
|
+
method: 'POST',
|
|
50
|
+
url: '/users/login',
|
|
51
|
+
status: 500,
|
|
52
|
+
app: 'auth',
|
|
53
|
+
});
|
|
54
|
+
expect(r.severity).toBe('critical');
|
|
55
|
+
expect(r.shouldReport).toBe(true);
|
|
56
|
+
});
|
|
31
57
|
});
|
|
32
58
|
describe('critical', () => {
|
|
33
59
|
it.each([500, 502, 503, 504])('%s → critical с status в fingerprint', (status) => {
|
package/dist/index.d.ts
CHANGED
|
@@ -25,7 +25,9 @@ export * from './pick';
|
|
|
25
25
|
export * from './promocode-error-codes';
|
|
26
26
|
export * from './promocode-nominal-rules';
|
|
27
27
|
export * from './redirect';
|
|
28
|
+
export * from './resolve-calendar-location-id';
|
|
28
29
|
export * from './role-chat-permissions';
|
|
30
|
+
export * from './role-comment-permissions';
|
|
29
31
|
export * from './role-export-permissions';
|
|
30
32
|
export * from './serialize-record';
|
|
31
33
|
export * from './serialize-slot';
|
package/dist/index.js
CHANGED
|
@@ -41,7 +41,9 @@ __exportStar(require("./pick"), exports);
|
|
|
41
41
|
__exportStar(require("./promocode-error-codes"), exports);
|
|
42
42
|
__exportStar(require("./promocode-nominal-rules"), exports);
|
|
43
43
|
__exportStar(require("./redirect"), exports);
|
|
44
|
+
__exportStar(require("./resolve-calendar-location-id"), exports);
|
|
44
45
|
__exportStar(require("./role-chat-permissions"), exports);
|
|
46
|
+
__exportStar(require("./role-comment-permissions"), exports);
|
|
45
47
|
__exportStar(require("./role-export-permissions"), exports);
|
|
46
48
|
__exportStar(require("./serialize-record"), exports);
|
|
47
49
|
__exportStar(require("./serialize-slot"), exports);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Отсев чужих ошибок, которые Sentry не может атрибутировать по стеку.
|
|
3
|
+
*
|
|
4
|
+
* `allowUrls` фильтрует по URL последнего стек-фрейма. У глобальных ошибок из
|
|
5
|
+
* чужого кода на странице (расширения браузера, инжекты аналитики, JS-мосты
|
|
6
|
+
* in-app браузеров) стек либо пустой, либо это одинокий `<anonymous>` или сам
|
|
7
|
+
* HTML-документ — URL не определяется, и Sentry такие события ПРОПУСКАЕТ.
|
|
8
|
+
*
|
|
9
|
+
* Пример с продакшена: ESCAPE-NAVIGATOR-ORDERS-25 «Error: ga» — единственный
|
|
10
|
+
* фрейм `https://orders.escapenavigator.ru/:415:45`, при том что отдаваемый
|
|
11
|
+
* документ длиной 27 строк. То есть скрипт дописан уже в браузере.
|
|
12
|
+
*
|
|
13
|
+
* Правило: у НЕОБРАБОТАННОЙ ошибки (onerror/onunhandledrejection) должен быть
|
|
14
|
+
* хотя бы один фрейм из нашего бандла — иначе дропаем. Ручные
|
|
15
|
+
* `captureException` не трогаем: у них handled-механизм, и короткий стек для
|
|
16
|
+
* них нормален.
|
|
17
|
+
*/
|
|
18
|
+
export type ForeignErrorStackFrame = {
|
|
19
|
+
filename?: string;
|
|
20
|
+
abs_path?: string;
|
|
21
|
+
};
|
|
22
|
+
export type ForeignErrorExceptionValue = {
|
|
23
|
+
mechanism?: {
|
|
24
|
+
type?: string;
|
|
25
|
+
handled?: boolean;
|
|
26
|
+
};
|
|
27
|
+
stacktrace?: {
|
|
28
|
+
frames?: ForeignErrorStackFrame[];
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export type ForeignErrorEvent = {
|
|
32
|
+
exception?: {
|
|
33
|
+
values?: ForeignErrorExceptionValue[];
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
export declare function isUnattributableForeignError(event: ForeignErrorEvent, bundleUrlPatterns: RegExp[]): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Паттерны для приложений, которые сами себя хостят (CRM, orders, auth).
|
|
39
|
+
*
|
|
40
|
+
* Важно требовать именно `.js`: у таких приложений документ лежит на том же
|
|
41
|
+
* origin, что и бандл, и матч по одному хосту пропустил бы инлайн-скрипты,
|
|
42
|
+
* дописанные в HTML расширением или in-app браузером.
|
|
43
|
+
*/
|
|
44
|
+
export declare function selfHostedBundlePatterns(hostPattern: string): RegExp[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Отсев чужих ошибок, которые Sentry не может атрибутировать по стеку.
|
|
4
|
+
*
|
|
5
|
+
* `allowUrls` фильтрует по URL последнего стек-фрейма. У глобальных ошибок из
|
|
6
|
+
* чужого кода на странице (расширения браузера, инжекты аналитики, JS-мосты
|
|
7
|
+
* in-app браузеров) стек либо пустой, либо это одинокий `<anonymous>` или сам
|
|
8
|
+
* HTML-документ — URL не определяется, и Sentry такие события ПРОПУСКАЕТ.
|
|
9
|
+
*
|
|
10
|
+
* Пример с продакшена: ESCAPE-NAVIGATOR-ORDERS-25 «Error: ga» — единственный
|
|
11
|
+
* фрейм `https://orders.escapenavigator.ru/:415:45`, при том что отдаваемый
|
|
12
|
+
* документ длиной 27 строк. То есть скрипт дописан уже в браузере.
|
|
13
|
+
*
|
|
14
|
+
* Правило: у НЕОБРАБОТАННОЙ ошибки (onerror/onunhandledrejection) должен быть
|
|
15
|
+
* хотя бы один фрейм из нашего бандла — иначе дропаем. Ручные
|
|
16
|
+
* `captureException` не трогаем: у них handled-механизм, и короткий стек для
|
|
17
|
+
* них нормален.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.isUnattributableForeignError = isUnattributableForeignError;
|
|
21
|
+
exports.selfHostedBundlePatterns = selfHostedBundlePatterns;
|
|
22
|
+
function isUnattributableForeignError(event, bundleUrlPatterns) {
|
|
23
|
+
const values = event.exception?.values;
|
|
24
|
+
if (!values?.length)
|
|
25
|
+
return false;
|
|
26
|
+
const isUnhandled = values.some((value) => {
|
|
27
|
+
const mechanism = value.mechanism;
|
|
28
|
+
if (!mechanism)
|
|
29
|
+
return false;
|
|
30
|
+
if (mechanism.handled === false)
|
|
31
|
+
return true;
|
|
32
|
+
return mechanism.type === 'onerror' || mechanism.type === 'onunhandledrejection';
|
|
33
|
+
});
|
|
34
|
+
if (!isUnhandled)
|
|
35
|
+
return false;
|
|
36
|
+
const frames = values.flatMap((value) => value.stacktrace?.frames ?? []);
|
|
37
|
+
const isOurFrame = (frame) => {
|
|
38
|
+
const url = frame.filename || frame.abs_path || '';
|
|
39
|
+
return bundleUrlPatterns.some((pattern) => pattern.test(url));
|
|
40
|
+
};
|
|
41
|
+
return !frames.some(isOurFrame);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Паттерны для приложений, которые сами себя хостят (CRM, orders, auth).
|
|
45
|
+
*
|
|
46
|
+
* Важно требовать именно `.js`: у таких приложений документ лежит на том же
|
|
47
|
+
* origin, что и бандл, и матч по одному хосту пропустил бы инлайн-скрипты,
|
|
48
|
+
* дописанные в HTML расширением или in-app браузером.
|
|
49
|
+
*/
|
|
50
|
+
function selfHostedBundlePatterns(hostPattern) {
|
|
51
|
+
return [
|
|
52
|
+
new RegExp(`^https?://${hostPattern}/[^?#]*\\.js`, 'i'),
|
|
53
|
+
/^https?:\/\/localhost:\d+\/[^?#]*\.(js|tsx?)/i,
|
|
54
|
+
];
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const is_foreign_error_1 = require("./is-foreign-error");
|
|
4
|
+
describe('isUnattributableForeignError', () => {
|
|
5
|
+
const patterns = (0, is_foreign_error_1.selfHostedBundlePatterns)('orders\\.escapenavigator\\.(ru|com)');
|
|
6
|
+
const unhandled = { type: 'onerror', handled: false };
|
|
7
|
+
it('drops the injected inline script from ORDERS-25 («Error: ga»)', () => {
|
|
8
|
+
// Единственный фрейм — сам документ на 415-й строке, хотя отдаваемый
|
|
9
|
+
// HTML длиной 27 строк: скрипт дописан в браузере.
|
|
10
|
+
expect((0, is_foreign_error_1.isUnattributableForeignError)({
|
|
11
|
+
exception: {
|
|
12
|
+
values: [
|
|
13
|
+
{
|
|
14
|
+
mechanism: unhandled,
|
|
15
|
+
stacktrace: {
|
|
16
|
+
frames: [{ filename: 'https://orders.escapenavigator.ru/' }],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
}, patterns)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('drops unhandled errors with no frames at all', () => {
|
|
24
|
+
expect((0, is_foreign_error_1.isUnattributableForeignError)({ exception: { values: [{ mechanism: unhandled }] } }, patterns)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
it('drops unhandled errors whose only frame is <anonymous>', () => {
|
|
27
|
+
expect((0, is_foreign_error_1.isUnattributableForeignError)({
|
|
28
|
+
exception: {
|
|
29
|
+
values: [
|
|
30
|
+
{
|
|
31
|
+
mechanism: unhandled,
|
|
32
|
+
stacktrace: { frames: [{ filename: '<anonymous>' }] },
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
}, patterns)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
it('keeps errors that have at least one frame from our bundle', () => {
|
|
39
|
+
expect((0, is_foreign_error_1.isUnattributableForeignError)({
|
|
40
|
+
exception: {
|
|
41
|
+
values: [
|
|
42
|
+
{
|
|
43
|
+
mechanism: unhandled,
|
|
44
|
+
stacktrace: {
|
|
45
|
+
frames: [
|
|
46
|
+
{ filename: 'https://orders.escapenavigator.ru/' },
|
|
47
|
+
{
|
|
48
|
+
filename: 'https://orders.escapenavigator.ru/assets/index-CE3a.js',
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
}, patterns)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
it('keeps localhost frames so dev builds still report', () => {
|
|
58
|
+
expect((0, is_foreign_error_1.isUnattributableForeignError)({
|
|
59
|
+
exception: {
|
|
60
|
+
values: [
|
|
61
|
+
{
|
|
62
|
+
mechanism: unhandled,
|
|
63
|
+
stacktrace: {
|
|
64
|
+
frames: [{ filename: 'http://localhost:3000/src/main.tsx' }],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
}, patterns)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
it('keeps handled captures — short stacks are normal for them', () => {
|
|
72
|
+
expect((0, is_foreign_error_1.isUnattributableForeignError)({
|
|
73
|
+
exception: {
|
|
74
|
+
values: [{ mechanism: { type: 'generic', handled: true } }],
|
|
75
|
+
},
|
|
76
|
+
}, patterns)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it('ignores events without exception values (messages, transactions)', () => {
|
|
79
|
+
expect((0, is_foreign_error_1.isUnattributableForeignError)({}, patterns)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
type ResolveCalendarLocationIdProps = {
|
|
2
|
+
/** Локация из URL (`?locationId=`) — приоритетный источник. */
|
|
3
|
+
requestedLocationId?: number | null;
|
|
4
|
+
/** Последняя выбранная локация из localStorage (ключ на профиль). */
|
|
5
|
+
storedLocationId?: number | null;
|
|
6
|
+
/** Локации, доступные текущему профилю. */
|
|
7
|
+
availableLocationIds: number[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Выбирает локацию, для которой календарь грузит слоты: URL → localStorage →
|
|
11
|
+
* первая доступная. Ключевое — отбросить id, которого у профиля нет.
|
|
12
|
+
*
|
|
13
|
+
* Так ломался календарь после смены компании: `loginSwitch` делает
|
|
14
|
+
* `window.location.reload()`, query-string при этом сохраняется, и в URL
|
|
15
|
+
* оставался `locationId` предыдущей компании. Календарь запрашивал слоты
|
|
16
|
+
* чужой локации, бэкенд (он скоупит выборку по `profileId`) честно отдавал
|
|
17
|
+
* пустой массив — оператор видел сетку без слотов и без всякой ошибки.
|
|
18
|
+
*
|
|
19
|
+
* Возвращает `null`, только если у профиля нет ни одной локации.
|
|
20
|
+
*/
|
|
21
|
+
export declare const resolveCalendarLocationId: ({ requestedLocationId, storedLocationId, availableLocationIds, }: ResolveCalendarLocationIdProps) => number | null;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveCalendarLocationId = void 0;
|
|
4
|
+
const isAvailable = (id, availableLocationIds) => typeof id === 'number' && Number.isFinite(id) && availableLocationIds.includes(id);
|
|
5
|
+
/**
|
|
6
|
+
* Выбирает локацию, для которой календарь грузит слоты: URL → localStorage →
|
|
7
|
+
* первая доступная. Ключевое — отбросить id, которого у профиля нет.
|
|
8
|
+
*
|
|
9
|
+
* Так ломался календарь после смены компании: `loginSwitch` делает
|
|
10
|
+
* `window.location.reload()`, query-string при этом сохраняется, и в URL
|
|
11
|
+
* оставался `locationId` предыдущей компании. Календарь запрашивал слоты
|
|
12
|
+
* чужой локации, бэкенд (он скоупит выборку по `profileId`) честно отдавал
|
|
13
|
+
* пустой массив — оператор видел сетку без слотов и без всякой ошибки.
|
|
14
|
+
*
|
|
15
|
+
* Возвращает `null`, только если у профиля нет ни одной локации.
|
|
16
|
+
*/
|
|
17
|
+
const resolveCalendarLocationId = ({ requestedLocationId, storedLocationId, availableLocationIds, }) => {
|
|
18
|
+
if (isAvailable(requestedLocationId, availableLocationIds))
|
|
19
|
+
return requestedLocationId;
|
|
20
|
+
if (isAvailable(storedLocationId, availableLocationIds))
|
|
21
|
+
return storedLocationId;
|
|
22
|
+
return availableLocationIds[0] ?? null;
|
|
23
|
+
};
|
|
24
|
+
exports.resolveCalendarLocationId = resolveCalendarLocationId;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const resolve_calendar_location_id_1 = require("./resolve-calendar-location-id");
|
|
4
|
+
describe('resolveCalendarLocationId', () => {
|
|
5
|
+
it('keeps the location from the URL when the profile owns it', () => {
|
|
6
|
+
expect((0, resolve_calendar_location_id_1.resolveCalendarLocationId)({
|
|
7
|
+
requestedLocationId: 7,
|
|
8
|
+
storedLocationId: 3,
|
|
9
|
+
availableLocationIds: [3, 7],
|
|
10
|
+
})).toBe(7);
|
|
11
|
+
});
|
|
12
|
+
it('falls back to the stored location when the URL one belongs to another company', () => {
|
|
13
|
+
expect((0, resolve_calendar_location_id_1.resolveCalendarLocationId)({
|
|
14
|
+
requestedLocationId: 999,
|
|
15
|
+
storedLocationId: 3,
|
|
16
|
+
availableLocationIds: [3, 7],
|
|
17
|
+
})).toBe(3);
|
|
18
|
+
});
|
|
19
|
+
it('falls back to the first location when nothing is known', () => {
|
|
20
|
+
expect((0, resolve_calendar_location_id_1.resolveCalendarLocationId)({
|
|
21
|
+
availableLocationIds: [3, 7],
|
|
22
|
+
})).toBe(3);
|
|
23
|
+
});
|
|
24
|
+
it('ignores broken ids', () => {
|
|
25
|
+
expect((0, resolve_calendar_location_id_1.resolveCalendarLocationId)({
|
|
26
|
+
requestedLocationId: NaN,
|
|
27
|
+
storedLocationId: NaN,
|
|
28
|
+
availableLocationIds: [3],
|
|
29
|
+
})).toBe(3);
|
|
30
|
+
expect((0, resolve_calendar_location_id_1.resolveCalendarLocationId)({
|
|
31
|
+
requestedLocationId: null,
|
|
32
|
+
storedLocationId: 0,
|
|
33
|
+
availableLocationIds: [3],
|
|
34
|
+
})).toBe(3);
|
|
35
|
+
});
|
|
36
|
+
it('returns null when the profile has no locations', () => {
|
|
37
|
+
expect((0, resolve_calendar_location_id_1.resolveCalendarLocationId)({
|
|
38
|
+
requestedLocationId: 7,
|
|
39
|
+
storedLocationId: 3,
|
|
40
|
+
availableLocationIds: [],
|
|
41
|
+
})).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { RoleRO } from '@escapenavigator/types/dist/role/role.ro';
|
|
2
|
+
export type RoleCommentPermissions = Pick<RoleRO, 'totalAccess' | 'canDeleteComments'>;
|
|
3
|
+
/**
|
|
4
|
+
* Право удалять чужие комментарии сотрудников. Свои — всегда можно.
|
|
5
|
+
* Владелец (`totalAccess`) — как будто флаг включён.
|
|
6
|
+
*/
|
|
7
|
+
export declare const canDeleteComments: (role?: RoleCommentPermissions | null) => boolean;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.canDeleteComments = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Право удалять чужие комментарии сотрудников. Свои — всегда можно.
|
|
6
|
+
* Владелец (`totalAccess`) — как будто флаг включён.
|
|
7
|
+
*/
|
|
8
|
+
const canDeleteComments = (role) => !!role?.totalAccess || !!role?.canDeleteComments;
|
|
9
|
+
exports.canDeleteComments = canDeleteComments;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const role_comment_permissions_1 = require("./role-comment-permissions");
|
|
4
|
+
describe('canDeleteComments', () => {
|
|
5
|
+
it('is false without role or flag', () => {
|
|
6
|
+
expect((0, role_comment_permissions_1.canDeleteComments)()).toBe(false);
|
|
7
|
+
expect((0, role_comment_permissions_1.canDeleteComments)(null)).toBe(false);
|
|
8
|
+
expect((0, role_comment_permissions_1.canDeleteComments)({ totalAccess: false, canDeleteComments: false })).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
it('is true when flag is on', () => {
|
|
11
|
+
expect((0, role_comment_permissions_1.canDeleteComments)({ totalAccess: false, canDeleteComments: true })).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it('is true for totalAccess even when flag is off', () => {
|
|
14
|
+
expect((0, role_comment_permissions_1.canDeleteComments)({ totalAccess: true, canDeleteComments: false })).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
});
|
package/dist/serialize-slot.js
CHANGED
|
@@ -36,6 +36,7 @@ const serializeSlotOrderData = (order) => order && {
|
|
|
36
36
|
promocodeTotal: order.promocodeTotal,
|
|
37
37
|
certificateTotal: order.certificateTotal,
|
|
38
38
|
upsellingsTotal: order.upsellingsTotal,
|
|
39
|
+
upsellingIcons: order.upsellingIcons ?? [],
|
|
39
40
|
emailTrouble: order.emailTrouble,
|
|
40
41
|
noShow: order.noShow,
|
|
41
42
|
// Иконка «есть комментарии»: денорм-флаг заметок сотрудников ИЛИ
|
|
@@ -63,6 +64,7 @@ const serializeSlot = ({ slot, orders = [] }) => ({
|
|
|
63
64
|
discount: slot.discount ?? 0,
|
|
64
65
|
onlyPhone: slot.onlyPhone ?? false,
|
|
65
66
|
forceOnlineBooking: slot.forceOnlineBooking ?? false,
|
|
67
|
+
ignoreResourceLimit: slot.ignoreResourceLimit ?? false,
|
|
66
68
|
breakReason: slot.breakReason ?? null,
|
|
67
69
|
availableTeams: slot.availableTeams ?? null,
|
|
68
70
|
numSeatsAvailable: slot.numSeatsAvailable ?? slot.availablePlayers ?? 0,
|
|
@@ -16,8 +16,37 @@ export type UserSessionPermissionTarget = {
|
|
|
16
16
|
authorId?: number | null;
|
|
17
17
|
date: string;
|
|
18
18
|
};
|
|
19
|
+
export type UserSessionClaimTarget = {
|
|
20
|
+
hold?: boolean;
|
|
21
|
+
userId?: number | null;
|
|
22
|
+
needReplace?: boolean;
|
|
23
|
+
start: string;
|
|
24
|
+
end: string;
|
|
25
|
+
locationId: number;
|
|
26
|
+
comment?: string | null;
|
|
27
|
+
};
|
|
28
|
+
export type UserSessionClaimDto = {
|
|
29
|
+
userId?: number | null;
|
|
30
|
+
needReplace?: boolean;
|
|
31
|
+
start?: string;
|
|
32
|
+
end?: string;
|
|
33
|
+
locationId?: number;
|
|
34
|
+
comment?: string | null;
|
|
35
|
+
hold?: boolean;
|
|
36
|
+
};
|
|
19
37
|
export declare const canEditOtherUserSessions: (role: UserSessionRolePermissions) => boolean;
|
|
20
38
|
export declare const isSessionDateTodayOrFuture: (date: string, today?: string) => boolean;
|
|
39
|
+
/** Unassigned shift or a shift with an open replace request — any employee may claim it. */
|
|
40
|
+
export declare const isOpenUserSession: (session: Pick<UserSessionClaimTarget, "userId" | "needReplace">) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* True when the actor is only claiming an open shift for themselves
|
|
43
|
+
* (assign userId / clear needReplace) without changing schedule fields.
|
|
44
|
+
*/
|
|
45
|
+
export declare const isTakingOpenUserSession: (params: {
|
|
46
|
+
session: UserSessionClaimTarget;
|
|
47
|
+
dto: UserSessionClaimDto;
|
|
48
|
+
actorId: number;
|
|
49
|
+
}) => boolean;
|
|
21
50
|
/** Returns an English denial message, or null when edit/delete is allowed. */
|
|
22
51
|
export declare const getUserSessionEditDenial: (params: {
|
|
23
52
|
session: UserSessionPermissionTarget;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.canEditUserSession = exports.getUserSessionCreateDenial = exports.getUserSessionEditDenial = exports.isSessionDateTodayOrFuture = exports.canEditOtherUserSessions = exports.USER_SESSION_ERRORS_EN = exports.USER_SESSION_ERROR_KEYS = void 0;
|
|
3
|
+
exports.canEditUserSession = exports.getUserSessionCreateDenial = exports.getUserSessionEditDenial = exports.isTakingOpenUserSession = exports.isOpenUserSession = exports.isSessionDateTodayOrFuture = exports.canEditOtherUserSessions = exports.USER_SESSION_ERRORS_EN = exports.USER_SESSION_ERROR_KEYS = void 0;
|
|
4
4
|
exports.USER_SESSION_ERROR_KEYS = {
|
|
5
5
|
shiftLocked: 'shiftLocked',
|
|
6
6
|
noPermission: 'noPermission',
|
|
@@ -23,6 +23,42 @@ const canEditOtherUserSessions = (role) => !!role.totalAccess || !!role.canEditW
|
|
|
23
23
|
exports.canEditOtherUserSessions = canEditOtherUserSessions;
|
|
24
24
|
const isSessionDateTodayOrFuture = (date, today = formatTodayYmd()) => today <= date;
|
|
25
25
|
exports.isSessionDateTodayOrFuture = isSessionDateTodayOrFuture;
|
|
26
|
+
/** Unassigned shift or a shift with an open replace request — any employee may claim it. */
|
|
27
|
+
const isOpenUserSession = (session) => session.userId == null || !!session.needReplace;
|
|
28
|
+
exports.isOpenUserSession = isOpenUserSession;
|
|
29
|
+
/**
|
|
30
|
+
* True when the actor is only claiming an open shift for themselves
|
|
31
|
+
* (assign userId / clear needReplace) without changing schedule fields.
|
|
32
|
+
*/
|
|
33
|
+
const isTakingOpenUserSession = (params) => {
|
|
34
|
+
const { session, dto, actorId } = params;
|
|
35
|
+
if (session.hold) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (!(0, exports.isOpenUserSession)(session)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (dto.userId !== actorId) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (dto.hold === true) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (dto.start != null && dto.start !== session.start) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (dto.end != null && dto.end !== session.end) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (dto.locationId != null && dto.locationId !== session.locationId) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (dto.comment != null && (dto.comment ?? '') !== (session.comment ?? '')) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
exports.isTakingOpenUserSession = isTakingOpenUserSession;
|
|
26
62
|
const formatTodayYmd = () => {
|
|
27
63
|
const d = new Date();
|
|
28
64
|
return `${d.getFullYear()}-${`${d.getMonth() + 1}`.padStart(2, '0')}-${`${d.getDate()}`.padStart(2, '0')}`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const user_session_permissions_1 = require("./user-session-permissions");
|
|
4
|
+
const openSession = {
|
|
5
|
+
hold: false,
|
|
6
|
+
userId: null,
|
|
7
|
+
needReplace: false,
|
|
8
|
+
start: '10:00',
|
|
9
|
+
end: '18:00',
|
|
10
|
+
locationId: 1,
|
|
11
|
+
comment: null,
|
|
12
|
+
};
|
|
13
|
+
describe('isOpenUserSession', () => {
|
|
14
|
+
it('is true when unassigned', () => {
|
|
15
|
+
expect((0, user_session_permissions_1.isOpenUserSession)({ userId: null, needReplace: false })).toBe(true);
|
|
16
|
+
expect((0, user_session_permissions_1.isOpenUserSession)({ userId: undefined, needReplace: false })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it('is true when replace was requested', () => {
|
|
19
|
+
expect((0, user_session_permissions_1.isOpenUserSession)({ userId: 5, needReplace: true })).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it('is false for a normal assigned shift', () => {
|
|
22
|
+
expect((0, user_session_permissions_1.isOpenUserSession)({ userId: 5, needReplace: false })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('isTakingOpenUserSession', () => {
|
|
26
|
+
it('allows any employee to claim an unassigned shift for themselves', () => {
|
|
27
|
+
expect((0, user_session_permissions_1.isTakingOpenUserSession)({
|
|
28
|
+
session: openSession,
|
|
29
|
+
dto: {
|
|
30
|
+
userId: 42,
|
|
31
|
+
needReplace: false,
|
|
32
|
+
start: '10:00',
|
|
33
|
+
end: '18:00',
|
|
34
|
+
locationId: 1,
|
|
35
|
+
comment: null,
|
|
36
|
+
},
|
|
37
|
+
actorId: 42,
|
|
38
|
+
})).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it('allows claiming a shift with an open replace request', () => {
|
|
41
|
+
expect((0, user_session_permissions_1.isTakingOpenUserSession)({
|
|
42
|
+
session: { ...openSession, userId: 7, needReplace: true },
|
|
43
|
+
dto: {
|
|
44
|
+
userId: 42,
|
|
45
|
+
needReplace: false,
|
|
46
|
+
start: '10:00',
|
|
47
|
+
end: '18:00',
|
|
48
|
+
locationId: 1,
|
|
49
|
+
},
|
|
50
|
+
actorId: 42,
|
|
51
|
+
})).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
it('rejects claiming for another employee', () => {
|
|
54
|
+
expect((0, user_session_permissions_1.isTakingOpenUserSession)({
|
|
55
|
+
session: openSession,
|
|
56
|
+
dto: { userId: 99, start: '10:00', end: '18:00', locationId: 1 },
|
|
57
|
+
actorId: 42,
|
|
58
|
+
})).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
it('rejects locked shifts', () => {
|
|
61
|
+
expect((0, user_session_permissions_1.isTakingOpenUserSession)({
|
|
62
|
+
session: { ...openSession, hold: true },
|
|
63
|
+
dto: { userId: 42, start: '10:00', end: '18:00', locationId: 1 },
|
|
64
|
+
actorId: 42,
|
|
65
|
+
})).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
it('rejects when schedule fields change alongside the claim', () => {
|
|
68
|
+
expect((0, user_session_permissions_1.isTakingOpenUserSession)({
|
|
69
|
+
session: openSession,
|
|
70
|
+
dto: { userId: 42, start: '11:00', end: '18:00', locationId: 1 },
|
|
71
|
+
actorId: 42,
|
|
72
|
+
})).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
it('rejects already assigned shifts without replace request', () => {
|
|
75
|
+
expect((0, user_session_permissions_1.isTakingOpenUserSession)({
|
|
76
|
+
session: { ...openSession, userId: 7, needReplace: false },
|
|
77
|
+
dto: { userId: 42, start: '10:00', end: '18:00', locationId: 1 },
|
|
78
|
+
actorId: 42,
|
|
79
|
+
})).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('getUserSessionEditDenial', () => {
|
|
83
|
+
it('still requires author for non-manager edit of someone else\'s unassigned shift', () => {
|
|
84
|
+
expect((0, user_session_permissions_1.getUserSessionEditDenial)({
|
|
85
|
+
session: { hold: false, authorId: 1, date: '2099-01-01' },
|
|
86
|
+
role: {
|
|
87
|
+
totalAccess: false,
|
|
88
|
+
canEditWorkedHours: false,
|
|
89
|
+
canEditOwnWorkedHours: true,
|
|
90
|
+
},
|
|
91
|
+
userId: 42,
|
|
92
|
+
})).toBe(user_session_permissions_1.USER_SESSION_ERRORS_EN.notShiftAuthor);
|
|
93
|
+
});
|
|
94
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escapenavigator/utils",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.34",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"test": "jest"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@escapenavigator/types": "^2.0.
|
|
17
|
+
"@escapenavigator/types": "^2.0.32",
|
|
18
18
|
"axios": "^0.21.4",
|
|
19
19
|
"class-transformer": "^0.5.1",
|
|
20
20
|
"class-validator": "^0.13.2",
|
|
@@ -29,5 +29,5 @@
|
|
|
29
29
|
"ts-jest": "^29.1.1",
|
|
30
30
|
"typescript": "^5.6"
|
|
31
31
|
},
|
|
32
|
-
"gitHead": "
|
|
32
|
+
"gitHead": "e7c356a5eb4bdb5d606be29aa0e8571073d0c531"
|
|
33
33
|
}
|