@cartanova/qgrid-cli 2.6.6 → 2.6.8

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.
Files changed (87) hide show
  1. package/bundle/dist/application/monit/monit.frame.js +2 -2
  2. package/bundle/dist/application/qgrid/expired-token-reminder.js +37 -16
  3. package/bundle/dist/application/qgrid/oauth.js +1 -1
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +3 -3
  5. package/bundle/dist/application/qgrid/slack-user-map.js +5 -14
  6. package/bundle/dist/application/qgrid/token-death.js +3 -2
  7. package/bundle/dist/application/qgrid/tool-emulation.js +1 -1
  8. package/bundle/dist/application/queries.generated.js +8 -2
  9. package/bundle/dist/application/request-log/request-log.model.js +2 -2
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
  11. package/bundle/dist/application/setting/setting.model.js +236 -0
  12. package/bundle/dist/application/setting/setting.schema.js +157 -0
  13. package/bundle/dist/application/setting/setting.store.js +66 -0
  14. package/bundle/dist/application/setting/setting.types.js +48 -0
  15. package/bundle/dist/application/sonamu.generated.js +32 -2
  16. package/bundle/dist/application/sonamu.generated.sso.js +12 -2
  17. package/bundle/dist/application/token/token.model.js +1 -1
  18. package/bundle/dist/i18n/en.js +3 -1
  19. package/bundle/dist/i18n/ko.js +3 -1
  20. package/bundle/dist/i18n/sd.generated.js +9 -1
  21. package/bundle/dist/migrations/20260808194708_create__settings.js +24 -0
  22. package/bundle/dist/sonamu.config.js +8 -2
  23. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +21 -6
  24. package/bundle/dist/utils/quiet-hours.js +43 -0
  25. package/bundle/dist/utils/slack-notify.js +12 -5
  26. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +135 -13
  27. package/bundle/src/application/qgrid/expired-token-reminder.ts +60 -21
  28. package/bundle/src/application/qgrid/slack-user-map.test.ts +23 -0
  29. package/bundle/src/application/qgrid/slack-user-map.ts +4 -14
  30. package/bundle/src/application/qgrid/token-death.ts +2 -0
  31. package/bundle/src/application/queries.generated.ts +21 -1
  32. package/bundle/src/application/setting/setting.entity.json +59 -0
  33. package/bundle/src/application/setting/setting.model.ts +263 -0
  34. package/bundle/src/application/setting/setting.schema.test.ts +80 -0
  35. package/bundle/src/application/setting/setting.schema.ts +174 -0
  36. package/bundle/src/application/setting/setting.store.test.ts +87 -0
  37. package/bundle/src/application/setting/setting.store.ts +85 -0
  38. package/bundle/src/application/setting/setting.types.ts +51 -0
  39. package/bundle/src/application/sonamu.generated.http +47 -0
  40. package/bundle/src/application/sonamu.generated.sso.ts +21 -0
  41. package/bundle/src/application/sonamu.generated.ts +51 -0
  42. package/bundle/src/i18n/en.ts +2 -0
  43. package/bundle/src/i18n/ko.ts +2 -0
  44. package/bundle/src/i18n/sd.generated.ts +8 -0
  45. package/bundle/src/migrations/20260808194708_create__settings.ts +19 -0
  46. package/bundle/src/sonamu.config.ts +10 -0
  47. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +12 -0
  48. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +43 -5
  49. package/bundle/src/utils/quiet-hours.ts +40 -0
  50. package/bundle/src/utils/slack-notify.test.ts +88 -20
  51. package/bundle/src/utils/slack-notify.ts +24 -4
  52. package/bundle/web-dist/client/assets/index-7SZyiqAx.css +1 -0
  53. package/bundle/web-dist/client/assets/index-DrsXE13r.js +78 -0
  54. package/bundle/web-dist/client/assets/{logs-CvmI4KuC.js → logs-e4-6aYnO.js} +1 -1
  55. package/bundle/web-dist/client/assets/{monit-CUueNzoL.js → monit-eOAlYTK9.js} +1 -1
  56. package/bundle/web-dist/client/assets/plus-p4vtg6LN.js +1 -0
  57. package/bundle/web-dist/client/assets/{routes-CAfjRsMY.js → routes-Qie4ogtJ.js} +2 -2
  58. package/bundle/web-dist/client/assets/{sd.generated-DYdwSB2I.js → sd.generated-Bwa7rses.js} +1 -1
  59. package/bundle/web-dist/client/assets/services.generated-T4ctVcia.js +1 -0
  60. package/bundle/web-dist/client/assets/settings-CTVZpAAg.js +1 -0
  61. package/bundle/web-dist/client/assets/{show-C5vKpp5z.js → show-60iOOM-e.js} +1 -1
  62. package/bundle/web-dist/client/assets/tokens-DdDccGlX.js +1 -0
  63. package/bundle/web-dist/client/assets/{use-oauth-login-flow-B3o4CXDW.js → use-oauth-login-flow-0ZGq74bZ.js} +1 -1
  64. package/bundle/web-dist/client/index.html +3 -3
  65. package/bundle/web-dist/server/assets/{lazyRouteComponent-BaZaveNc.js → lazyRouteComponent-Ws7I6fL-.js} +2 -1
  66. package/bundle/web-dist/server/assets/{logs-DMOw3SjD.js → logs-DmkzGxGU.js} +4 -4
  67. package/bundle/web-dist/server/assets/{monit-UmcqHroX.js → monit-DyKLJX4s.js} +3 -2
  68. package/bundle/web-dist/server/assets/plus-BISXNqRU.js +19 -0
  69. package/bundle/web-dist/server/assets/react-dom-D7duWOwf.js +462 -0
  70. package/bundle/web-dist/server/assets/{routes-BfoQmYud.js → routes-g62E9xmC.js} +5 -4
  71. package/bundle/web-dist/server/assets/{sd.generated-B0xqnHEo.js → sd.generated-LO-h1_16.js} +13 -461
  72. package/bundle/web-dist/server/assets/{services.generated-DqAKskAS.js → services.generated-DADr9OGH.js} +111 -2
  73. package/bundle/web-dist/server/assets/settings-CMJKayff.js +332 -0
  74. package/bundle/web-dist/server/assets/{show-C-oSdzHX.js → show-BGAiCPGq.js} +4 -4
  75. package/bundle/web-dist/server/assets/{tokens-Dkd_uXEk.js → tokens-DmhIfVct.js} +6 -21
  76. package/bundle/web-dist/server/assets/{use-oauth-login-flow-YM1nsd-H.js → use-oauth-login-flow-B6MY65IN.js} +2 -2
  77. package/bundle/web-dist/server/entry-server.generated.js +54 -18
  78. package/package.json +1 -1
  79. package/skills/qgrid/references/cli-env-and-server-boot.md +14 -2
  80. package/skills/qgrid/references/openai-codex-runtime.md +1 -1
  81. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
  82. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -1
  83. package/bundle/web-dist/client/assets/index-B4GlN1IX.css +0 -1
  84. package/bundle/web-dist/client/assets/index-qs8K8bOv.js +0 -78
  85. package/bundle/web-dist/client/assets/services.generated-BsAvotjG.js +0 -1
  86. package/bundle/web-dist/client/assets/tokens-C9KKP2Yq.js +0 -1
  87. /package/bundle/web-dist/server/assets/{cost-CSH_t17I.js → cost-DzjfVtUr.js} +0 -0
@@ -0,0 +1,43 @@
1
+ //#region src/utils/quiet-hours.ts
2
+ /**
3
+ * Slack 알림 조용 시간.
4
+ *
5
+ * 토큰 만료는 사람이 재로그인해야 풀리는데, 그 사람이 자거나 쉬는 동안 알려도 할 수 있는
6
+ * 일이 없다. 평일 업무 시간에만 알리고 나머지는 다음 근무 시간으로 미룬다 — 만료 알림은
7
+ * 주기적으로 반복되므로 조용 시간에 버려도 아침 첫 주기에 다시 온다.
8
+ *
9
+ * 다만 provider 의 마지막 토큰이 죽는 것은 서비스 정지 신호라 이 규칙에서 제외한다
10
+ * (호출부가 `notifySlack` 을 그냥 호출하면 된다).
11
+ */
12
+ /** 이 시각부터(포함) 조용해진다. */
13
+ const QUIET_FROM_HOUR = 20;
14
+ /** 이 시각부터(포함) 다시 알린다. */
15
+ const QUIET_UNTIL_HOUR = 8;
16
+ /**
17
+ * 서버가 UTC 로 돌아도 한국 시간으로 판정한다. dev0 는 UTC 이므로 `getHours()` 를 그대로
18
+ * 쓰면 "저녁 8시"가 한국 시간 새벽 5시가 된다.
19
+ */
20
+ function seoulParts(at) {
21
+ const parts = new Intl.DateTimeFormat("en-US", {
22
+ timeZone: "Asia/Seoul",
23
+ hour: "numeric",
24
+ hour12: false,
25
+ weekday: "short"
26
+ }).formatToParts(at);
27
+ const hour = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
28
+ const weekday = parts.find((p) => p.type === "weekday")?.value ?? "";
29
+ return {
30
+ hour: hour % 24,
31
+ weekday
32
+ };
33
+ }
34
+ /** 주말이거나 20시~익일 8시면 조용 시간. */
35
+ function isQuietHours(at = /* @__PURE__ */ new Date()) {
36
+ const { hour, weekday } = seoulParts(at);
37
+ if (weekday === "Sat" || weekday === "Sun") return true;
38
+ return hour >= QUIET_FROM_HOUR || hour < QUIET_UNTIL_HOUR;
39
+ }
40
+ //#endregion
41
+ export { isQuietHours };
42
+
43
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicXVpZXQtaG91cnMuanMiLCJuYW1lcyI6W10sInNvdXJjZXMiOlsiLi4vLi4vc3JjL3V0aWxzL3F1aWV0LWhvdXJzLnRzIl0sInNvdXJjZXNDb250ZW50IjpbIi8qKlxuICogU2xhY2sg7JWM66a8IOyhsOyaqSDsi5zqsIQuXG4gKlxuICog7Yag7YGwIOunjOujjOuKlCDsgqzrnozsnbQg7J6s66Gc6re47J247ZW07JW8IO2SgOumrOuKlOuNsCwg6re4IOyCrOuejOydtCDsnpDqsbDrgpgg7Ims64qUIOuPmeyViCDslYzroKTrj4Qg7ZWgIOyImCDsnojripRcbiAqIOydvOydtCDsl4bri6QuIO2PieydvCDsl4XrrLQg7Iuc6rCE7JeQ66eMIOyVjOumrOqzoCDrgpjrqLjsp4DripQg64uk7J2MIOq3vOustCDsi5zqsITsnLzroZwg66+466Os64ukIOKAlCDrp4zro4wg7JWM66a87J2AXG4gKiDso7zquLDsoIHsnLzroZwg67CY67O165CY66+A66GcIOyhsOyaqSDsi5zqsITsl5Ag67KE66Ck64+EIOyVhOy5qCDssqsg7KO86riw7JeQIOuLpOyLnCDsmKjri6QuXG4gKlxuICog64uk66eMIHByb3ZpZGVyIOydmCDrp4jsp4Drp4kg7Yag7YGw7J20IOyjveuKlCDqsoPsnYAg7ISc67mE7IqkIOygleyngCDsi6DtmLjrnbwg7J20IOq3nOy5meyXkOyEnCDsoJzsmbjtlZzri6RcbiAqICjtmLjstpzrtoDqsIAgYG5vdGlmeVNsYWNrYCDsnYQg6re464OlIO2YuOy2nO2VmOuptCDrkJzri6QpLlxuICovXG5cbi8qKiDsnbQg7Iuc6rCB67aA7YSwKO2PrO2VqCkg7KGw7Jqp7ZW07KeE64ukLiAqL1xuY29uc3QgUVVJRVRfRlJPTV9IT1VSID0gMjA7XG4vKiog7J20IOyLnOqwgeu2gO2EsCjtj6ztlagpIOuLpOyLnCDslYzrprDri6QuICovXG5jb25zdCBRVUlFVF9VTlRJTF9IT1VSID0gODtcblxuLyoqXG4gKiDshJzrsoTqsIAgVVRDIOuhnCDrj4zslYTrj4Qg7ZWc6rWtIOyLnOqwhOycvOuhnCDtjJDsoJXtlZzri6QuIGRldjAg64qUIFVUQyDsnbTrr4DroZwgYGdldEhvdXJzKClgIOulvCDqt7jrjIDroZxcbiAqIOyTsOuptCBcIuyggOuFgSA47IucXCLqsIAg7ZWc6rWtIOyLnOqwhCDsg4jrsr0gNeyLnOqwgCDrkJzri6QuXG4gKi9cbmZ1bmN0aW9uIHNlb3VsUGFydHMoYXQ6IERhdGUpOiB7IGhvdXI6IG51bWJlcjsgd2Vla2RheTogc3RyaW5nIH0ge1xuICBjb25zdCBmb3JtYXR0ZXIgPSBuZXcgSW50bC5EYXRlVGltZUZvcm1hdChcImVuLVVTXCIsIHtcbiAgICB0aW1lWm9uZTogXCJBc2lhL1Nlb3VsXCIsXG4gICAgaG91cjogXCJudW1lcmljXCIsXG4gICAgaG91cjEyOiBmYWxzZSxcbiAgICB3ZWVrZGF5OiBcInNob3J0XCIsXG4gIH0pO1xuICBjb25zdCBwYXJ0cyA9IGZvcm1hdHRlci5mb3JtYXRUb1BhcnRzKGF0KTtcbiAgY29uc3QgaG91ciA9IE51bWJlcihwYXJ0cy5maW5kKChwKSA9PiBwLnR5cGUgPT09IFwiaG91clwiKT8udmFsdWUgPz8gXCIwXCIpO1xuICBjb25zdCB3ZWVrZGF5ID0gcGFydHMuZmluZCgocCkgPT4gcC50eXBlID09PSBcIndlZWtkYXlcIik/LnZhbHVlID8/IFwiXCI7XG4gIC8vIEludGwg7J2AIOyekOygleydhCAyNCDroZwg7KSEIOyImCDsnojri6QuIDAuLjIzIOycvOuhnCDrp57stpjri6QuXG4gIHJldHVybiB7IGhvdXI6IGhvdXIgJSAyNCwgd2Vla2RheSB9O1xufVxuXG4vKiog7KO866eQ7J206rGw64KYIDIw7IucfuydteydvCA47Iuc66m0IOyhsOyaqSDsi5zqsIQuICovXG5leHBvcnQgZnVuY3Rpb24gaXNRdWlldEhvdXJzKGF0OiBEYXRlID0gbmV3IERhdGUoKSk6IGJvb2xlYW4ge1xuICBjb25zdCB7IGhvdXIsIHdlZWtkYXkgfSA9IHNlb3VsUGFydHMoYXQpO1xuICBpZiAod2Vla2RheSA9PT0gXCJTYXRcIiB8fCB3ZWVrZGF5ID09PSBcIlN1blwiKSByZXR1cm4gdHJ1ZTtcbiAgcmV0dXJuIGhvdXIgPj0gUVVJRVRfRlJPTV9IT1VSIHx8IGhvdXIgPCBRVUlFVF9VTlRJTF9IT1VSO1xufVxuIl0sIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7Ozs7QUFZQSxNQUFNLGtCQUFrQjs7QUFFeEIsTUFBTSxtQkFBbUI7Ozs7O0FBTXpCLFNBQVMsV0FBVyxJQUE2QztDQU8vRCxNQUFNLFFBQVEsSUFOUSxLQUFLLGVBQWUsU0FBUztFQUNqRCxVQUFVO0VBQ1YsTUFBTTtFQUNOLFFBQVE7RUFDUixTQUFTO0NBQ1gsQ0FDYyxDQUFBLENBQVUsY0FBYyxFQUFFO0NBQ3hDLE1BQU0sT0FBTyxPQUFPLE1BQU0sTUFBTSxNQUFNLEVBQUUsU0FBUyxNQUFNLENBQUMsRUFBRSxTQUFTLEdBQUc7Q0FDdEUsTUFBTSxVQUFVLE1BQU0sTUFBTSxNQUFNLEVBQUUsU0FBUyxTQUFTLENBQUMsRUFBRSxTQUFTO0NBRWxFLE9BQU87RUFBRSxNQUFNLE9BQU87RUFBSTtDQUFRO0FBQ3BDOztBQUdBLFNBQWdCLGFBQWEscUJBQVcsSUFBSSxLQUFLLEdBQVk7Q0FDM0QsTUFBTSxFQUFFLE1BQU0sWUFBWSxXQUFXLEVBQUU7Q0FDdkMsSUFBSSxZQUFZLFNBQVMsWUFBWSxPQUFPLE9BQU87Q0FDbkQsT0FBTyxRQUFRLG1CQUFtQixPQUFPO0FBQzNDIn0=
@@ -1,9 +1,12 @@
1
+ import { getSetting } from "../application/setting/setting.store.js";
2
+ import { isQuietHours } from "./quiet-hours.js";
1
3
  import { getLogger } from "@logtape/logtape";
2
4
  //#region src/utils/slack-notify.ts
3
5
  /**
4
6
  * Slack 알림 (chat.postMessage).
5
7
  *
6
8
  * fail-open: env 미설정이면 조용히 no-op 이고, 전송 실패는 warn 로그로만 남긴다.
9
+ * 조용 시간(주말·20~8시)에는 `urgent` 가 아닌 알림을 보내지 않는다.
7
10
  * 알림 경로가 토큰 라우팅이나 오류 전파를 지연·차단해서는 안 된다.
8
11
  */
9
12
  const logger = getLogger(["qgrid", "slack"]);
@@ -14,14 +17,18 @@ const SLACK_COLOR = {
14
17
  good: "#2eb886",
15
18
  bad: "#e01e5a"
16
19
  };
17
- async function notifySlack(notification) {
18
- const botToken = process.env.SLACK_BOT_TOKEN;
19
- const channel = process.env.SLACK_CHANNEL_ID;
20
- const { title, subject, context, color } = notification;
20
+ async function notifySlack(notification, readSetting = getSetting) {
21
+ const botToken = readSetting("slack.botToken", "SLACK_BOT_TOKEN");
22
+ const channel = readSetting("slack.channelId", "SLACK_CHANNEL_ID");
23
+ const { title, subject, context, color, urgent, now } = notification;
21
24
  if (!botToken || !channel) {
22
25
  logger.debug(`slack not configured, skipping notification: ${title} ${subject ?? ""}`);
23
26
  return;
24
27
  }
28
+ if (!urgent && isQuietHours(now)) {
29
+ logger.debug(`quiet hours, skipping notification: ${title} ${subject ?? ""}`);
30
+ return;
31
+ }
25
32
  const blocks = [{
26
33
  type: "section",
27
34
  text: {
@@ -62,4 +69,4 @@ async function notifySlack(notification) {
62
69
  //#endregion
63
70
  export { SLACK_COLOR, notifySlack };
64
71
 
65
- //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2xhY2stbm90aWZ5LmpzIiwibmFtZXMiOltdLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy91dGlscy9zbGFjay1ub3RpZnkudHMiXSwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBTbGFjayDslYzrprwgKGNoYXQucG9zdE1lc3NhZ2UpLlxuICpcbiAqIGZhaWwtb3BlbjogZW52IOuvuOyEpOygleydtOuptCDsobDsmqntnoggbm8tb3Ag7J206rOgLCDsoITshqEg7Iuk7Yyo64qUIHdhcm4g66Gc6re466Gc66eMIOuCqOq4tOuLpC5cbiAqIOyVjOumvCDqsr3roZzqsIAg7Yag7YGwIOudvOyasO2MheydtOuCmCDsmKTrpZgg7KCE7YyM66W8IOyngOyXsMK37LCo64uo7ZW07ISc64qUIOyViCDrkJzri6QuXG4gKi9cbmltcG9ydCB7IGdldExvZ2dlciB9IGZyb20gXCJAbG9ndGFwZS9sb2d0YXBlXCI7XG5cbmNvbnN0IGxvZ2dlciA9IGdldExvZ2dlcihbXCJxZ3JpZFwiLCBcInNsYWNrXCJdKTtcblxuY29uc3QgU0xBQ0tfUE9TVF9NRVNTQUdFX1VSTCA9IFwiaHR0cHM6Ly9zbGFjay5jb20vYXBpL2NoYXQucG9zdE1lc3NhZ2VcIjtcbmNvbnN0IFNMQUNLX1RJTUVPVVRfTVMgPSA1XzAwMDtcblxuLyoqIOyDge2DnOulvCDsg4nsnLzroZwg66i87KCAIOydve2eiOqyjCDtlZjripQgYXR0YWNobWVudCDsg4nsg4Eg67CULiAqL1xuZXhwb3J0IGNvbnN0IFNMQUNLX0NPTE9SID0ge1xuICBnb29kOiBcIiMyZWI4ODZcIixcbiAgYmFkOiBcIiNlMDFlNWFcIixcbn0gYXMgY29uc3Q7XG5cbmV4cG9ydCB0eXBlIFNsYWNrTm90aWZpY2F0aW9uID0ge1xuICAvKiog7ZWcIOykhCDsoJzrqqkuIOustOyKqCDsnbzsnbQg7J287Ja064Ks64qU7KeA66eMIOuLtOuKlOuLpC4gKi9cbiAgdGl0bGU6IHN0cmluZztcbiAgLyoqIOygnOuqqSDsmIYg7L2U65OcIOyKpO2DgOydvOuhnCDrtpnripQg64yA7IOBIOyLneuzhOyekC4gKi9cbiAgc3ViamVjdD86IHN0cmluZztcbiAgLyoqIOygnOuqqSDslYTrnpgg7J6R7J2AIOq4gOyUqOuhnCDrgrTroKTqsIDripQg67aA6rCAIOygleuztCDigJQg7J297KeAIOyViuyVhOuPhCDrkJjripQg6rKD65OkLiAqL1xuICBjb250ZXh0Pzogc3RyaW5nO1xuICBjb2xvcj86ICh0eXBlb2YgU0xBQ0tfQ09MT1IpW2tleW9mIHR5cGVvZiBTTEFDS19DT0xPUl07XG59O1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gbm90aWZ5U2xhY2sobm90aWZpY2F0aW9uOiBTbGFja05vdGlmaWNhdGlvbik6IFByb21pc2U8dm9pZD4ge1xuICBjb25zdCBib3RUb2tlbiA9IHByb2Nlc3MuZW52LlNMQUNLX0JPVF9UT0tFTjtcbiAgY29uc3QgY2hhbm5lbCA9IHByb2Nlc3MuZW52LlNMQUNLX0NIQU5ORUxfSUQ7XG4gIGNvbnN0IHsgdGl0bGUsIHN1YmplY3QsIGNvbnRleHQsIGNvbG9yIH0gPSBub3RpZmljYXRpb247XG4gIGlmICghYm90VG9rZW4gfHwgIWNoYW5uZWwpIHtcbiAgICBsb2dnZXIuZGVidWcoYHNsYWNrIG5vdCBjb25maWd1cmVkLCBza2lwcGluZyBub3RpZmljYXRpb246ICR7dGl0bGV9ICR7c3ViamVjdCA/PyBcIlwifWApO1xuICAgIHJldHVybjtcbiAgfVxuXG4gIGNvbnN0IGJsb2NrczogdW5rbm93bltdID0gW1xuICAgIHtcbiAgICAgIHR5cGU6IFwic2VjdGlvblwiLFxuICAgICAgdGV4dDogeyB0eXBlOiBcIm1ya2R3blwiLCB0ZXh0OiBzdWJqZWN0ID8gYCoke3RpdGxlfSogIFxcYCR7c3ViamVjdH1cXGBgIDogYCoke3RpdGxlfSpgIH0sXG4gICAgfSxcbiAgXTtcbiAgaWYgKGNvbnRleHQpIHtcbiAgICBibG9ja3MucHVzaCh7IHR5cGU6IFwiY29udGV4dFwiLCBlbGVtZW50czogW3sgdHlwZTogXCJtcmtkd25cIiwgdGV4dDogY29udGV4dCB9XSB9KTtcbiAgfVxuXG4gIHRyeSB7XG4gICAgY29uc3QgcmVzID0gYXdhaXQgZmV0Y2goU0xBQ0tfUE9TVF9NRVNTQUdFX1VSTCwge1xuICAgICAgbWV0aG9kOiBcIlBPU1RcIixcbiAgICAgIGhlYWRlcnM6IHtcbiAgICAgICAgXCJDb250ZW50LVR5cGVcIjogXCJhcHBsaWNhdGlvbi9qc29uOyBjaGFyc2V0PXV0Zi04XCIsXG4gICAgICAgIEF1dGhvcml6YXRpb246IGBCZWFyZXIgJHtib3RUb2tlbn1gLFxuICAgICAgfSxcbiAgICAgIGJvZHk6IEpTT04uc3RyaW5naWZ5KHtcbiAgICAgICAgY2hhbm5lbCxcbiAgICAgICAgLy8gZmFsbGJhY2sg7J2AIGF0dGFjaG1lbnQg7JWI7JeQIOuRlOuLpC4g7LWc7IOB7JyEIHRleHQg66GcIOuztOuCtOuptCBibG9ja3Mg6rCAIOygleyDgSDroIzrjZTrkJjripRcbiAgICAgICAgLy8g7ZmU66m07JeQ7ISc64+EIO2VqOq7mCDstpzroKXrj7wg7KCc66qp7J20IOuRkCDrsogg67O07J2464ukLiBhdHRhY2htZW50IGZhbGxiYWNrIOydgCBibG9ja3Mg66W8XG4gICAgICAgIC8vIOuquyDqt7jrpqzripQg7YG065287J207Ja47Yq47JmAIO2RuOyLnCDslYzrprzsl5DshJzrp4wg7JOw7J2464ukLlxuICAgICAgICBhdHRhY2htZW50czogW1xuICAgICAgICAgIHtcbiAgICAgICAgICAgIGZhbGxiYWNrOiBzdWJqZWN0ID8gYCR7dGl0bGV9IOKAlCAke3N1YmplY3R9YCA6IHRpdGxlLFxuICAgICAgICAgICAgLi4uKGNvbG9yID8geyBjb2xvciB9IDoge30pLFxuICAgICAgICAgICAgYmxvY2tzLFxuICAgICAgICAgIH0sXG4gICAgICAgIF0sXG4gICAgICB9KSxcbiAgICAgIHNpZ25hbDogQWJvcnRTaWduYWwudGltZW91dChTTEFDS19USU1FT1VUX01TKSxcbiAgICB9KTtcblxuICAgIC8vIGJvdCDrr7jstIjrjIAobm90X2luX2NoYW5uZWwpwrfsnpjrqrvrkJwg7LGE64SQIOuTsSDqsIDsnqUg7Z2U7ZWcIOyYpOyEpOygleydgCBIVFRQIDIwMCArIG9rOmZhbHNlIOuhnCDsmKjri6QuXG4gICAgLy8gc3RhdHVzIOunjCDrs7TrqbQg7KGw7Jqp7Z6IIOyEseqztSDsspjrpqzrj7wg7JWM66a87J20IO2GteynuOuhnCDsgqzrnbzsp4Tri6QuXG4gICAgY29uc3QgYm9keSA9IChhd2FpdCByZXMuanNvbigpLmNhdGNoKCgpID0+IG51bGwpKSBhcyB7IG9rPzogYm9vbGVhbjsgZXJyb3I/OiBzdHJpbmcgfSB8IG51bGw7XG4gICAgaWYgKCFib2R5Py5vaykge1xuICAgICAgbG9nZ2VyLndhcm4oYHNsYWNrIG5vdGlmaWNhdGlvbiByZWplY3RlZDogJHtib2R5Py5lcnJvciA/PyBgaHR0cCAke3Jlcy5zdGF0dXN9YH1gKTtcbiAgICB9XG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBsb2dnZXIud2Fybihgc2xhY2sgbm90aWZpY2F0aW9uIGZhaWxlZDogJHsoZSBhcyBFcnJvcikubWVzc2FnZX1gKTtcbiAgfVxufVxuIl0sIm1hcHBpbmdzIjoiOzs7Ozs7OztBQVFBLE1BQU0sU0FBUyxVQUFVLENBQUMsU0FBUyxPQUFPLENBQUM7QUFFM0MsTUFBTSx5QkFBeUI7QUFDL0IsTUFBTSxtQkFBbUI7O0FBR3pCLE1BQWEsY0FBYztDQUN6QixNQUFNO0NBQ04sS0FBSztBQUNQO0FBWUEsZUFBc0IsWUFBWSxjQUFnRDtDQUNoRixNQUFNLFdBQVcsUUFBUSxJQUFJO0NBQzdCLE1BQU0sVUFBVSxRQUFRLElBQUk7Q0FDNUIsTUFBTSxFQUFFLE9BQU8sU0FBUyxTQUFTLFVBQVU7Q0FDM0MsSUFBSSxDQUFDLFlBQVksQ0FBQyxTQUFTO0VBQ3pCLE9BQU8sTUFBTSxnREFBZ0QsTUFBTSxHQUFHLFdBQVcsSUFBSTtFQUNyRjtDQUNGO0NBRUEsTUFBTSxTQUFvQixDQUN4QjtFQUNFLE1BQU07RUFDTixNQUFNO0dBQUUsTUFBTTtHQUFVLE1BQU0sVUFBVSxJQUFJLE1BQU0sT0FBTyxRQUFRLE1BQU0sSUFBSSxNQUFNO0VBQUc7Q0FDdEYsQ0FDRjtDQUNBLElBQUksU0FDRixPQUFPLEtBQUs7RUFBRSxNQUFNO0VBQVcsVUFBVSxDQUFDO0dBQUUsTUFBTTtHQUFVLE1BQU07RUFBUSxDQUFDO0NBQUUsQ0FBQztDQUdoRixJQUFJO0VBQ0YsTUFBTSxNQUFNLE1BQU0sTUFBTSx3QkFBd0I7R0FDOUMsUUFBUTtHQUNSLFNBQVM7SUFDUCxnQkFBZ0I7SUFDaEIsZUFBZSxVQUFVO0dBQzNCO0dBQ0EsTUFBTSxLQUFLLFVBQVU7SUFDbkI7SUFJQSxhQUFhLENBQ1g7S0FDRSxVQUFVLFVBQVUsR0FBRyxNQUFNLEtBQUssWUFBWTtLQUM5QyxHQUFJLFFBQVEsRUFBRSxNQUFNLElBQUksQ0FBQztLQUN6QjtJQUNGLENBQ0Y7R0FDRixDQUFDO0dBQ0QsUUFBUSxZQUFZLFFBQVEsZ0JBQWdCO0VBQzlDLENBQUM7RUFJRCxNQUFNLE9BQVEsTUFBTSxJQUFJLEtBQUssQ0FBQyxDQUFDLFlBQVksSUFBSTtFQUMvQyxJQUFJLENBQUMsTUFBTSxJQUNULE9BQU8sS0FBSyxnQ0FBZ0MsTUFBTSxTQUFTLFFBQVEsSUFBSSxVQUFVO0NBRXJGLFNBQVMsR0FBRztFQUNWLE9BQU8sS0FBSyw4QkFBK0IsRUFBWSxTQUFTO0NBQ2xFO0FBQ0YifQ==
72
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2xhY2stbm90aWZ5LmpzIiwibmFtZXMiOltdLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy91dGlscy9zbGFjay1ub3RpZnkudHMiXSwic291cmNlc0NvbnRlbnQiOlsiLyoqXG4gKiBTbGFjayDslYzrprwgKGNoYXQucG9zdE1lc3NhZ2UpLlxuICpcbiAqIGZhaWwtb3BlbjogZW52IOuvuOyEpOygleydtOuptCDsobDsmqntnoggbm8tb3Ag7J206rOgLCDsoITshqEg7Iuk7Yyo64qUIHdhcm4g66Gc6re466Gc66eMIOuCqOq4tOuLpC5cbiAqIOyhsOyaqSDsi5zqsIQo7KO866eQwrcyMH447IucKeyXkOuKlCBgdXJnZW50YCDqsIAg7JWE64uMIOyVjOumvOydhCDrs7TrgrTsp4Ag7JWK64qU64ukLlxuICog7JWM66a8IOqyveuhnOqwgCDthqDtgbAg65287Jqw7YyF7J2064KYIOyYpOulmCDsoITtjIzrpbwg7KeA7JewwrfssKjri6jtlbTshJzripQg7JWIIOuQnOuLpC5cbiAqL1xuaW1wb3J0IHsgZ2V0TG9nZ2VyIH0gZnJvbSBcIkBsb2d0YXBlL2xvZ3RhcGVcIjtcblxuaW1wb3J0IHsgZ2V0U2V0dGluZyB9IGZyb20gXCIuLi9hcHBsaWNhdGlvbi9zZXR0aW5nL3NldHRpbmcuc3RvcmVcIjtcbmltcG9ydCB7IGlzUXVpZXRIb3VycyB9IGZyb20gXCIuL3F1aWV0LWhvdXJzXCI7XG5cbmNvbnN0IGxvZ2dlciA9IGdldExvZ2dlcihbXCJxZ3JpZFwiLCBcInNsYWNrXCJdKTtcblxuY29uc3QgU0xBQ0tfUE9TVF9NRVNTQUdFX1VSTCA9IFwiaHR0cHM6Ly9zbGFjay5jb20vYXBpL2NoYXQucG9zdE1lc3NhZ2VcIjtcbmNvbnN0IFNMQUNLX1RJTUVPVVRfTVMgPSA1XzAwMDtcblxuLyoqIOyDge2DnOulvCDsg4nsnLzroZwg66i87KCAIOydve2eiOqyjCDtlZjripQgYXR0YWNobWVudCDsg4nsg4Eg67CULiAqL1xuZXhwb3J0IGNvbnN0IFNMQUNLX0NPTE9SID0ge1xuICBnb29kOiBcIiMyZWI4ODZcIixcbiAgYmFkOiBcIiNlMDFlNWFcIixcbn0gYXMgY29uc3Q7XG5cbmV4cG9ydCB0eXBlIFNsYWNrTm90aWZpY2F0aW9uID0ge1xuICAvKiog7ZWcIOykhCDsoJzrqqkuIOustOyKqCDsnbzsnbQg7J287Ja064Ks64qU7KeA66eMIOuLtOuKlOuLpC4gKi9cbiAgdGl0bGU6IHN0cmluZztcbiAgLyoqIOygnOuqqSDsmIYg7L2U65OcIOyKpO2DgOydvOuhnCDrtpnripQg64yA7IOBIOyLneuzhOyekC4gKi9cbiAgc3ViamVjdD86IHN0cmluZztcbiAgLyoqIOygnOuqqSDslYTrnpgg7J6R7J2AIOq4gOyUqOuhnCDrgrTroKTqsIDripQg67aA6rCAIOygleuztCDigJQg7J297KeAIOyViuyVhOuPhCDrkJjripQg6rKD65OkLiAqL1xuICBjb250ZXh0Pzogc3RyaW5nO1xuICBjb2xvcj86ICh0eXBlb2YgU0xBQ0tfQ09MT1IpW2tleW9mIHR5cGVvZiBTTEFDS19DT0xPUl07XG4gIC8qKlxuICAgKiDsobDsmqkg7Iuc6rCEKOyjvOunkMK3MjB+OOyLnCnsl5Drj4Qg67O064K464ukLiDshJzruYTsiqTqsIAg66mI7LaYIOyDge2DnOyymOufvCDsp4DquIgg7JWM7KeAIOyViuycvOuptCDshpDtlbTqsIBcbiAgICog7Luk7KeA64qUIOyCrOqxtOyXkOunjCDsk7Tri6Qg4oCUIOuCqOyaqe2VmOuptCDsobDsmqkg7Iuc6rCEIOyekOyytOqwgCDrrLTsnZjrr7jtlbTsp4Tri6QuXG4gICAqL1xuICB1cmdlbnQ/OiBib29sZWFuO1xuICAvKiog7KGw7JqpIOyLnOqwhCDtjJDsoJUg6riw7KSAIOyLnOqwgS4g7YWM7Iqk7Yq47JeQ7IScIOyLnOqzhOulvCDqs6DsoJXtlaAg65WM66eMIOuEmOq4tOuLpC4gKi9cbiAgbm93PzogRGF0ZTtcbn07XG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBub3RpZnlTbGFjayhcbiAgbm90aWZpY2F0aW9uOiBTbGFja05vdGlmaWNhdGlvbixcbiAgcmVhZFNldHRpbmc6IHR5cGVvZiBnZXRTZXR0aW5nID0gZ2V0U2V0dGluZyxcbik6IFByb21pc2U8dm9pZD4ge1xuICBjb25zdCBib3RUb2tlbiA9IHJlYWRTZXR0aW5nKFwic2xhY2suYm90VG9rZW5cIiwgXCJTTEFDS19CT1RfVE9LRU5cIik7XG4gIGNvbnN0IGNoYW5uZWwgPSByZWFkU2V0dGluZyhcInNsYWNrLmNoYW5uZWxJZFwiLCBcIlNMQUNLX0NIQU5ORUxfSURcIik7XG4gIGNvbnN0IHsgdGl0bGUsIHN1YmplY3QsIGNvbnRleHQsIGNvbG9yLCB1cmdlbnQsIG5vdyB9ID0gbm90aWZpY2F0aW9uO1xuICBpZiAoIWJvdFRva2VuIHx8ICFjaGFubmVsKSB7XG4gICAgbG9nZ2VyLmRlYnVnKGBzbGFjayBub3QgY29uZmlndXJlZCwgc2tpcHBpbmcgbm90aWZpY2F0aW9uOiAke3RpdGxlfSAke3N1YmplY3QgPz8gXCJcIn1gKTtcbiAgICByZXR1cm47XG4gIH1cblxuICAvLyDrp4zro4wg7JWM66a87J2AIOyjvOq4sOyggeycvOuhnCDrsJjrs7XrkJjrr4DroZwg7Jes6riw7IScIOuyhOugpOuPhCDri6TsnYwg6re866y0IOyLnOqwhCDssqsg7KO86riw7JeQIOuLpOyLnCDsmKjri6QuXG4gIGlmICghdXJnZW50ICYmIGlzUXVpZXRIb3Vycyhub3cpKSB7XG4gICAgbG9nZ2VyLmRlYnVnKGBxdWlldCBob3Vycywgc2tpcHBpbmcgbm90aWZpY2F0aW9uOiAke3RpdGxlfSAke3N1YmplY3QgPz8gXCJcIn1gKTtcbiAgICByZXR1cm47XG4gIH1cblxuICBjb25zdCBibG9ja3M6IHVua25vd25bXSA9IFtcbiAgICB7XG4gICAgICB0eXBlOiBcInNlY3Rpb25cIixcbiAgICAgIHRleHQ6IHsgdHlwZTogXCJtcmtkd25cIiwgdGV4dDogc3ViamVjdCA/IGAqJHt0aXRsZX0qICBcXGAke3N1YmplY3R9XFxgYCA6IGAqJHt0aXRsZX0qYCB9LFxuICAgIH0sXG4gIF07XG4gIGlmIChjb250ZXh0KSB7XG4gICAgYmxvY2tzLnB1c2goeyB0eXBlOiBcImNvbnRleHRcIiwgZWxlbWVudHM6IFt7IHR5cGU6IFwibXJrZHduXCIsIHRleHQ6IGNvbnRleHQgfV0gfSk7XG4gIH1cblxuICB0cnkge1xuICAgIGNvbnN0IHJlcyA9IGF3YWl0IGZldGNoKFNMQUNLX1BPU1RfTUVTU0FHRV9VUkwsIHtcbiAgICAgIG1ldGhvZDogXCJQT1NUXCIsXG4gICAgICBoZWFkZXJzOiB7XG4gICAgICAgIFwiQ29udGVudC1UeXBlXCI6IFwiYXBwbGljYXRpb24vanNvbjsgY2hhcnNldD11dGYtOFwiLFxuICAgICAgICBBdXRob3JpemF0aW9uOiBgQmVhcmVyICR7Ym90VG9rZW59YCxcbiAgICAgIH0sXG4gICAgICBib2R5OiBKU09OLnN0cmluZ2lmeSh7XG4gICAgICAgIGNoYW5uZWwsXG4gICAgICAgIC8vIGZhbGxiYWNrIOydgCBhdHRhY2htZW50IOyViOyXkCDrkZTri6QuIOy1nOyDgeychCB0ZXh0IOuhnCDrs7TrgrTrqbQgYmxvY2tzIOqwgCDsoJXsg4Eg66CM642U65CY64qUXG4gICAgICAgIC8vIO2ZlOuptOyXkOyEnOuPhCDtlajqu5gg7Lac66Cl64+8IOygnOuqqeydtCDrkZAg67KIIOuztOyduOuLpC4gYXR0YWNobWVudCBmYWxsYmFjayDsnYAgYmxvY2tzIOulvFxuICAgICAgICAvLyDrqrsg6re466as64qUIO2BtOudvOydtOyWuO2KuOyZgCDtkbjsi5wg7JWM66a87JeQ7ISc66eMIOyTsOyduOuLpC5cbiAgICAgICAgYXR0YWNobWVudHM6IFtcbiAgICAgICAgICB7XG4gICAgICAgICAgICBmYWxsYmFjazogc3ViamVjdCA/IGAke3RpdGxlfSDigJQgJHtzdWJqZWN0fWAgOiB0aXRsZSxcbiAgICAgICAgICAgIC4uLihjb2xvciA/IHsgY29sb3IgfSA6IHt9KSxcbiAgICAgICAgICAgIGJsb2NrcyxcbiAgICAgICAgICB9LFxuICAgICAgICBdLFxuICAgICAgfSksXG4gICAgICBzaWduYWw6IEFib3J0U2lnbmFsLnRpbWVvdXQoU0xBQ0tfVElNRU9VVF9NUyksXG4gICAgfSk7XG5cbiAgICAvLyBib3Qg66+47LSI64yAKG5vdF9pbl9jaGFubmVsKcK37J6Y66q765CcIOyxhOuEkCDrk7Eg6rCA7J6lIO2dlO2VnCDsmKTshKTsoJXsnYAgSFRUUCAyMDAgKyBvazpmYWxzZSDroZwg7Jio64ukLlxuICAgIC8vIHN0YXR1cyDrp4wg67O066m0IOyhsOyaqe2eiCDshLHqs7Ug7LKY66as64+8IOyVjOumvOydtCDthrXsp7jroZwg7IKs65287KeE64ukLlxuICAgIGNvbnN0IGJvZHkgPSAoYXdhaXQgcmVzLmpzb24oKS5jYXRjaCgoKSA9PiBudWxsKSkgYXMgeyBvaz86IGJvb2xlYW47IGVycm9yPzogc3RyaW5nIH0gfCBudWxsO1xuICAgIGlmICghYm9keT8ub2spIHtcbiAgICAgIGxvZ2dlci53YXJuKGBzbGFjayBub3RpZmljYXRpb24gcmVqZWN0ZWQ6ICR7Ym9keT8uZXJyb3IgPz8gYGh0dHAgJHtyZXMuc3RhdHVzfWB9YCk7XG4gICAgfVxuICB9IGNhdGNoIChlKSB7XG4gICAgbG9nZ2VyLndhcm4oYHNsYWNrIG5vdGlmaWNhdGlvbiBmYWlsZWQ6ICR7KGUgYXMgRXJyb3IpLm1lc3NhZ2V9YCk7XG4gIH1cbn1cbiJdLCJtYXBwaW5ncyI6Ijs7Ozs7Ozs7Ozs7QUFZQSxNQUFNLFNBQVMsVUFBVSxDQUFDLFNBQVMsT0FBTyxDQUFDO0FBRTNDLE1BQU0seUJBQXlCO0FBQy9CLE1BQU0sbUJBQW1COztBQUd6QixNQUFhLGNBQWM7Q0FDekIsTUFBTTtDQUNOLEtBQUs7QUFDUDtBQW1CQSxlQUFzQixZQUNwQixjQUNBLGNBQWlDLFlBQ2xCO0NBQ2YsTUFBTSxXQUFXLFlBQVksa0JBQWtCLGlCQUFpQjtDQUNoRSxNQUFNLFVBQVUsWUFBWSxtQkFBbUIsa0JBQWtCO0NBQ2pFLE1BQU0sRUFBRSxPQUFPLFNBQVMsU0FBUyxPQUFPLFFBQVEsUUFBUTtDQUN4RCxJQUFJLENBQUMsWUFBWSxDQUFDLFNBQVM7RUFDekIsT0FBTyxNQUFNLGdEQUFnRCxNQUFNLEdBQUcsV0FBVyxJQUFJO0VBQ3JGO0NBQ0Y7Q0FHQSxJQUFJLENBQUMsVUFBVSxhQUFhLEdBQUcsR0FBRztFQUNoQyxPQUFPLE1BQU0sdUNBQXVDLE1BQU0sR0FBRyxXQUFXLElBQUk7RUFDNUU7Q0FDRjtDQUVBLE1BQU0sU0FBb0IsQ0FDeEI7RUFDRSxNQUFNO0VBQ04sTUFBTTtHQUFFLE1BQU07R0FBVSxNQUFNLFVBQVUsSUFBSSxNQUFNLE9BQU8sUUFBUSxNQUFNLElBQUksTUFBTTtFQUFHO0NBQ3RGLENBQ0Y7Q0FDQSxJQUFJLFNBQ0YsT0FBTyxLQUFLO0VBQUUsTUFBTTtFQUFXLFVBQVUsQ0FBQztHQUFFLE1BQU07R0FBVSxNQUFNO0VBQVEsQ0FBQztDQUFFLENBQUM7Q0FHaEYsSUFBSTtFQUNGLE1BQU0sTUFBTSxNQUFNLE1BQU0sd0JBQXdCO0dBQzlDLFFBQVE7R0FDUixTQUFTO0lBQ1AsZ0JBQWdCO0lBQ2hCLGVBQWUsVUFBVTtHQUMzQjtHQUNBLE1BQU0sS0FBSyxVQUFVO0lBQ25CO0lBSUEsYUFBYSxDQUNYO0tBQ0UsVUFBVSxVQUFVLEdBQUcsTUFBTSxLQUFLLFlBQVk7S0FDOUMsR0FBSSxRQUFRLEVBQUUsTUFBTSxJQUFJLENBQUM7S0FDekI7SUFDRixDQUNGO0dBQ0YsQ0FBQztHQUNELFFBQVEsWUFBWSxRQUFRLGdCQUFnQjtFQUM5QyxDQUFDO0VBSUQsTUFBTSxPQUFRLE1BQU0sSUFBSSxLQUFLLENBQUMsQ0FBQyxZQUFZLElBQUk7RUFDL0MsSUFBSSxDQUFDLE1BQU0sSUFDVCxPQUFPLEtBQUssZ0NBQWdDLE1BQU0sU0FBUyxRQUFRLElBQUksVUFBVTtDQUVyRixTQUFTLEdBQUc7RUFDVixPQUFPLEtBQUssOEJBQStCLEVBQVksU0FBUztDQUNsRTtBQUNGIn0=
@@ -1,6 +1,19 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- import { buildReminderContext } from "./expired-token-reminder";
3
+ const { findInactiveMock, getSettingMock, notifySlackMock } = vi.hoisted(() => ({
4
+ findInactiveMock: vi.fn(),
5
+ getSettingMock: vi.fn(),
6
+ notifySlackMock: vi.fn(),
7
+ }));
8
+
9
+ import {
10
+ buildReminderContext,
11
+ type ExpiredTokenReminderDeps,
12
+ rescheduleExpiredTokenReminder,
13
+ startExpiredTokenReminder,
14
+ stopExpiredTokenReminder,
15
+ } from "./expired-token-reminder";
16
+ import { getSlackUserMap } from "./slack-user-map";
4
17
 
5
18
  const userMap = new Map([
6
19
  ["yds", "U09NAJUQSFQ"],
@@ -8,22 +21,22 @@ const userMap = new Map([
8
21
  ]);
9
22
 
10
23
  describe("buildReminderContext", () => {
11
- it("매핑된 토큰은 멘션으로 개인에게 꽂는다", () => {
24
+ it("1건이면 제목에 있는 토큰명을 본문에서 반복하지 않는다", () => {
12
25
  const text = buildReminderContext([{ name: "anthropic/yds", provider: "anthropic" }], userMap);
13
26
 
14
- expect(text).toBe("<@U09NAJUQSFQ> anthropic/yds");
27
+ expect(text).toBe("<@U09NAJUQSFQ>\n재로그인이 필요합니다");
15
28
  });
16
29
 
17
- it("공용 계정처럼 매핑이 없으면 멘션 없이 이름만 남긴다", () => {
30
+ it("1건이고 매핑이 없으면 안내만 남는다", () => {
18
31
  const text = buildReminderContext(
19
32
  [{ name: "anthropic/dev-common", provider: "anthropic" }],
20
33
  userMap,
21
34
  );
22
35
 
23
- expect(text).toBe("anthropic/dev-common");
36
+ expect(text).toBe("재로그인이 필요합니다");
24
37
  });
25
38
 
26
- it("같은 사람의 provider 별 토큰을 각각 구분해 보여준다", () => {
39
+ it("여러 건이면 제목이 건수라 본문에 토큰명을 남긴다", () => {
27
40
  const text = buildReminderContext(
28
41
  [
29
42
  { name: "anthropic/yds", provider: "anthropic" },
@@ -32,22 +45,131 @@ describe("buildReminderContext", () => {
32
45
  userMap,
33
46
  );
34
47
 
35
- // 멘션만 두 번 나오면 어느 토큰인지 알 수 없다 — 토큰명이 함께 있어야 한다.
36
48
  expect(text.split("\n")).toEqual([
37
49
  "<@U09NAJUQSFQ> anthropic/yds",
38
50
  "<@U09NAJUQSFQ> openai/yds",
51
+ "재로그인이 필요합니다",
39
52
  ]);
40
53
  });
41
54
 
42
- it("provider prefix 없이 저장된 이름도 매핑한다", () => {
43
- const text = buildReminderContext([{ name: "haze", provider: "anthropic" }], userMap);
55
+ it("여러 건 중 매핑 없는 공용 계정은 이름만 남긴다", () => {
56
+ const text = buildReminderContext(
57
+ [
58
+ { name: "anthropic/haze", provider: "anthropic" },
59
+ { name: "anthropic/dev-common", provider: "anthropic" },
60
+ ],
61
+ userMap,
62
+ );
44
63
 
45
- expect(text).toBe("<@U09N96NHZB7> haze");
64
+ expect(text.split("\n")).toEqual([
65
+ "<@U09N96NHZB7> anthropic/haze",
66
+ "anthropic/dev-common",
67
+ "재로그인이 필요합니다",
68
+ ]);
46
69
  });
47
70
 
48
71
  it("이름이 없는 토큰도 줄을 잃지 않는다", () => {
49
- const text = buildReminderContext([{ name: null, provider: "openai" }], userMap);
72
+ const text = buildReminderContext(
73
+ [
74
+ { name: null, provider: "openai" },
75
+ { name: "anthropic/haze", provider: "anthropic" },
76
+ ],
77
+ userMap,
78
+ );
79
+
80
+ expect(text.split("\n")).toEqual([
81
+ "unnamed",
82
+ "<@U09N96NHZB7> anthropic/haze",
83
+ "재로그인이 필요합니다",
84
+ ]);
85
+ });
86
+ });
87
+
88
+ describe("expired token reminder scheduling", () => {
89
+ let minutes = "10";
90
+ let rawUserMap = "yds:U-OLD";
91
+ let deps: ExpiredTokenReminderDeps;
92
+
93
+ beforeEach(() => {
94
+ vi.useFakeTimers();
95
+ minutes = "10";
96
+ rawUserMap = "yds:U-OLD";
97
+ findInactiveMock.mockReset();
98
+ getSettingMock.mockReset();
99
+ notifySlackMock.mockReset();
100
+ findInactiveMock.mockResolvedValue([{ name: "anthropic/yds", provider: "anthropic" }]);
101
+ getSettingMock.mockImplementation((key: string) => {
102
+ if (key === "slack.expiryReminderMinutes") return minutes;
103
+ if (key === "slack.userMap") return rawUserMap;
104
+ return undefined;
105
+ });
106
+ notifySlackMock.mockResolvedValue(undefined);
107
+ deps = {
108
+ findInactive: findInactiveMock,
109
+ getSlackUserMap: () => getSlackUserMap(getSettingMock),
110
+ readSetting: getSettingMock,
111
+ sendSlack: notifySlackMock,
112
+ };
113
+ });
114
+
115
+ afterEach(() => {
116
+ stopExpiredTokenReminder();
117
+ vi.useRealTimers();
118
+ });
119
+
120
+ it("부팅 시 만료 토큰을 즉시 한 번 알린다", async () => {
121
+ startExpiredTokenReminder(deps);
122
+ await vi.advanceTimersByTimeAsync(0);
123
+
124
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it("start를 다시 호출해도 interval은 하나만 남는다", async () => {
128
+ startExpiredTokenReminder(deps);
129
+ startExpiredTokenReminder(deps);
130
+ await vi.advanceTimersByTimeAsync(0);
131
+ notifySlackMock.mockClear();
132
+
133
+ await vi.advanceTimersByTimeAsync(10 * 60_000);
134
+
135
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ it("설정 변경은 즉시 알리지 않고 새 주기로 재예약한다", async () => {
139
+ startExpiredTokenReminder(deps);
140
+ await vi.advanceTimersByTimeAsync(0);
141
+ notifySlackMock.mockClear();
142
+ minutes = "20";
143
+
144
+ rescheduleExpiredTokenReminder(deps);
145
+ await vi.advanceTimersByTimeAsync(19 * 60_000);
146
+ expect(notifySlackMock).not.toHaveBeenCalled();
147
+
148
+ await vi.advanceTimersByTimeAsync(60_000);
149
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ it.each(["0", "-1", "not-a-number"])("%s로 재설정하면 기존 타이머를 멈춘다", async (next) => {
153
+ startExpiredTokenReminder(deps);
154
+ await vi.advanceTimersByTimeAsync(0);
155
+ notifySlackMock.mockClear();
156
+ minutes = next;
157
+
158
+ rescheduleExpiredTokenReminder(deps);
159
+ await vi.advanceTimersByTimeAsync(20 * 60_000);
160
+
161
+ expect(notifySlackMock).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("각 실행 시점의 최신 Slack 사용자 매핑을 사용한다", async () => {
165
+ startExpiredTokenReminder(deps);
166
+ await vi.advanceTimersByTimeAsync(0);
167
+ expect(notifySlackMock.mock.calls[0]![0]).toMatchObject({ context: "<@U-OLD>\n재로그인이 필요합니다" });
168
+ notifySlackMock.mockClear();
169
+ rawUserMap = "yds:U-NEW";
170
+
171
+ await vi.advanceTimersByTimeAsync(10 * 60_000);
50
172
 
51
- expect(text).toBe("unnamed");
173
+ expect(notifySlackMock.mock.calls[0]![0]).toMatchObject({ context: "<@U-NEW>\n재로그인이 필요합니다" });
52
174
  });
53
175
  });
@@ -13,6 +13,7 @@
13
13
  import { getLogger } from "@logtape/logtape";
14
14
 
15
15
  import { notifySlack, SLACK_COLOR } from "../../utils/slack-notify";
16
+ import { getSetting } from "../setting/setting.store";
16
17
  import { TokenModel } from "../token/token.model";
17
18
  import { getSlackUserMap, mentionFor } from "./slack-user-map";
18
19
 
@@ -20,51 +21,89 @@ const logger = getLogger(["qgrid", "expired-reminder"]);
20
21
 
21
22
  const MINUTE_MS = 60_000;
22
23
 
24
+ export type ExpiredTokenReminderDeps = {
25
+ findInactive: () => Promise<{ name: string | null; provider: string }[]>;
26
+ getSlackUserMap: () => Map<string, string>;
27
+ readSetting: typeof getSetting;
28
+ sendSlack: typeof notifySlack;
29
+ };
30
+
31
+ const defaultDeps: ExpiredTokenReminderDeps = {
32
+ findInactive: () => TokenModel.findInactive("A"),
33
+ getSlackUserMap,
34
+ readSetting: getSetting,
35
+ sendSlack: notifySlack,
36
+ };
37
+
38
+ /**
39
+ * 1건이면 제목(subject)에 토큰명이 이미 있으므로 본문에서는 뺀다 — 같은 이름을 두 번 쓰면
40
+ * 읽는 사람이 다른 정보인 줄 알고 한 번 더 본다. 여러 건이면 제목이 건수라 본문에 남긴다.
41
+ */
23
42
  export function buildReminderContext(
24
43
  tokens: { name: string | null; provider: string }[],
25
44
  userMap: Map<string, string>,
26
45
  ): string {
27
- return tokens
28
- .map((token) => {
29
- const mention = mentionFor(token.name, userMap);
30
- const label = token.name ?? "unnamed";
31
- // 멘션이 있어도 어떤 토큰인지는 남긴다 — 한 사람이 provider 별로 여러 개를 가진다.
32
- return mention ? `${mention} ${label}` : label;
33
- })
34
- .join("\n");
46
+ const showLabel = tokens.length > 1;
47
+ const lines = tokens.map((token) => {
48
+ const mention = mentionFor(token.name, userMap);
49
+ const label = token.name ?? "unnamed";
50
+ if (!showLabel) return mention;
51
+ return mention ? `${mention} ${label}` : label;
52
+ });
53
+ return [...lines, "재로그인이 필요합니다"].filter(Boolean).join("\n");
35
54
  }
36
55
 
37
- async function sendReminder(userMap: Map<string, string>): Promise<void> {
38
- const inactive = await TokenModel.findInactive("A");
56
+ async function sendReminder(deps: ExpiredTokenReminderDeps): Promise<void> {
57
+ const inactive = await deps.findInactive();
39
58
  if (inactive.length === 0) return;
40
59
 
41
- await notifySlack({
60
+ const userMap = deps.getSlackUserMap();
61
+
62
+ await deps.sendSlack({
42
63
  // 만료 순간 알림과 같은 제목을 쓴다. 같은 사건이므로 다른 이름을 붙이면 별개 문제로 읽힌다.
43
64
  title: "세션 만료",
44
65
  subject: inactive.length > 1 ? `${inactive.length}건` : (inactive[0]!.name ?? "unnamed"),
45
- context: `${buildReminderContext(inactive, userMap)}\n재로그인이 필요합니다`,
66
+ context: buildReminderContext(inactive, userMap),
46
67
  color: SLACK_COLOR.bad,
47
68
  });
48
69
  }
49
70
 
50
71
  let timer: ReturnType<typeof setInterval> | null = null;
51
72
 
73
+ function scheduleExpiredTokenReminder(runNow: boolean, deps: ExpiredTokenReminderDeps): void {
74
+ stopExpiredTokenReminder();
75
+
76
+ const minutes = Number(
77
+ deps.readSetting("slack.expiryReminderMinutes", "SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES") ?? 0,
78
+ );
79
+ if (!Number.isFinite(minutes) || minutes <= 0) return;
80
+
81
+ logger.info(
82
+ `expired token reminder every ${minutes}m (${deps.getSlackUserMap().size} user mappings)`,
83
+ );
84
+
85
+ const run = () =>
86
+ sendReminder(deps).catch((e) => logger.warn(`reminder failed: ${(e as Error).message}`));
87
+
88
+ if (runNow) void run();
89
+ timer = setInterval(run, minutes * MINUTE_MS);
90
+ }
91
+
52
92
  /**
53
93
  * `SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES` 가 양수일 때만 켜진다. 미설정·0 이면 비활성.
54
94
  *
55
95
  * 인스턴스가 여러 개면 각자 보낸다. 만료 순간 알림과 달리 상태 변화가 없어 DB 로 억제할 수
56
96
  * 없고, 하루 몇 건 수준이라 중복을 감수하는 편이 잠금 장치를 두는 것보다 낫다.
57
97
  */
58
- export function startExpiredTokenReminder(): void {
59
- const minutes = Number(process.env.SLACK_EXPIRY_REMINDER_INTERVAL_MINUTES ?? 0);
60
- if (!Number.isFinite(minutes) || minutes <= 0) return;
61
-
62
- const userMap = getSlackUserMap();
63
- logger.info(`expired token reminder every ${minutes}m (${userMap.size} user mappings)`);
98
+ export function startExpiredTokenReminder(deps: ExpiredTokenReminderDeps = defaultDeps): void {
99
+ // 기동 직후에도 한 번 보낸다. 재기동은 배포나 장애 복구 때 일어나는데, 그 시점에 이미
100
+ // 만료된 토큰이 있으면 첫 주기가 돌 때까지 조용한 것이 이상하다.
101
+ scheduleExpiredTokenReminder(true, deps);
102
+ }
64
103
 
65
- timer = setInterval(() => {
66
- sendReminder(userMap).catch((e) => logger.warn(`reminder failed: ${(e as Error).message}`));
67
- }, minutes * MINUTE_MS);
104
+ /** 설정 변경 시 기존 타이머만 새 주기로 교체한다. 저장 자체가 알림을 보내지는 않는다. */
105
+ export function rescheduleExpiredTokenReminder(deps: ExpiredTokenReminderDeps = defaultDeps): void {
106
+ scheduleExpiredTokenReminder(false, deps);
68
107
  }
69
108
 
70
109
  export function stopExpiredTokenReminder(): void {
@@ -0,0 +1,23 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { getSlackUserMap } from "./slack-user-map";
4
+
5
+ const getSettingMock = vi.fn();
6
+
7
+ describe("getSlackUserMap", () => {
8
+ let raw = "yds:U-OLD";
9
+
10
+ beforeEach(() => {
11
+ raw = "yds:U-OLD";
12
+ getSettingMock.mockReset();
13
+ getSettingMock.mockImplementation(() => raw);
14
+ });
15
+
16
+ it("설정이 바뀌면 다음 조회부터 새 매핑을 사용한다", () => {
17
+ expect(getSlackUserMap(getSettingMock).get("yds")).toBe("U-OLD");
18
+
19
+ raw = "yds:U-NEW";
20
+
21
+ expect(getSlackUserMap(getSettingMock).get("yds")).toBe("U-NEW");
22
+ });
23
+ });
@@ -9,6 +9,8 @@
9
9
  * 맞는 동작이다.
10
10
  */
11
11
 
12
+ import { getSetting } from "../setting/setting.store";
13
+
12
14
  /** `토큰명:SlackUserId` 목록을 파싱한다. */
13
15
  export function parseSlackUserMap(raw: string | undefined): Map<string, string> {
14
16
  const map = new Map<string, string>();
@@ -34,18 +36,6 @@ export function mentionFor(tokenName: string | null, userMap: Map<string, string
34
36
  return userId ? `<@${userId}>` : "";
35
37
  }
36
38
 
37
- /**
38
- * 환경변수는 프로세스 수명 동안 고정이라 매번 파싱할 이유가 없다. 사망 알림은 요청 경로에서
39
- * 호출되므로 캐시해 둔다.
40
- */
41
- let cached: Map<string, string> | null = null;
42
-
43
- export function getSlackUserMap(): Map<string, string> {
44
- cached ??= parseSlackUserMap(process.env.SLACK_USER_MAP);
45
- return cached;
46
- }
47
-
48
- /** 테스트에서 env 를 바꿔 끼울 때 쓴다. */
49
- export function resetSlackUserMapCache(): void {
50
- cached = null;
39
+ export function getSlackUserMap(readSetting: typeof getSetting = getSetting): Map<string, string> {
40
+ return parseSlackUserMap(readSetting("slack.userMap", "SLACK_USER_MAP"));
51
41
  }
@@ -89,6 +89,8 @@ function notifyLastActiveTokenDying(token: DeadTokenRef, reasonCode: string): vo
89
89
  ` · 풀이 비지 않도록 유지 중입니다. ` +
90
90
  `전 토큰이 동시에 실패했다면 client_id 취소나 OAuth 계약 변경을 의심하세요`,
91
91
  color: SLACK_COLOR.bad,
92
+ // provider 전체가 죽었다는 신호다. 조용 시간에 묻히면 다음 근무일까지 서비스가 멈춘다.
93
+ urgent: true,
92
94
  });
93
95
  }
94
96
 
@@ -19,7 +19,13 @@ function createSSRQuery(
19
19
 
20
20
  import { RequestLogStepListParams } from "./request-log-step/request-log-step.types";
21
21
  import { RequestLogListParams } from "./request-log/request-log.types";
22
- import { TokenSubsetKey, RequestLogStepSubsetKey, RequestLogSubsetKey } from "./sonamu.generated";
22
+ import { SettingListParams } from "./setting/setting.types";
23
+ import {
24
+ TokenSubsetKey,
25
+ SettingSubsetKey,
26
+ RequestLogStepSubsetKey,
27
+ RequestLogSubsetKey,
28
+ } from "./sonamu.generated";
23
29
  import { TokenListParams } from "./token/token.types";
24
30
 
25
31
  export namespace TokenService {
@@ -33,6 +39,20 @@ export namespace TokenService {
33
39
  createSSRQuery("TokenModel", "findMany", [subset, rawParams], ["Token", "getTokens"]);
34
40
  }
35
41
 
42
+ export namespace SettingService {
43
+ export const getSetting = <T extends SettingSubsetKey>(subset: T, id: number): SSRQuery =>
44
+ createSSRQuery("SettingModel", "findById", [subset, id], ["Setting", "getSetting"]);
45
+
46
+ export const getSettings = <T extends SettingSubsetKey, LP extends SettingListParams>(
47
+ subset: T,
48
+ rawParams?: LP,
49
+ ): SSRQuery =>
50
+ createSSRQuery("SettingModel", "findMany", [subset, rawParams], ["Setting", "getSettings"]);
51
+
52
+ export const getSettingList = (): SSRQuery =>
53
+ createSSRQuery("SettingModel", "listSettings", [], ["Setting", "getSettingList"]);
54
+ }
55
+
36
56
  export namespace RequestLogStepService {
37
57
  export const getRequestLogStep = <T extends RequestLogStepSubsetKey>(
38
58
  subset: T,
@@ -0,0 +1,59 @@
1
+ {
2
+ "id": "Setting",
3
+ "table": "settings",
4
+ "title": "Setting",
5
+ "props": [
6
+ {
7
+ "name": "id",
8
+ "type": "integer",
9
+ "desc": "ID"
10
+ },
11
+ {
12
+ "name": "created_at",
13
+ "desc": "등록일시",
14
+ "dbDefault": "CURRENT_TIMESTAMP",
15
+ "type": "date"
16
+ },
17
+ {
18
+ "name": "key",
19
+ "type": "string",
20
+ "length": 100,
21
+ "desc": "설정 키 (예: openai.maxWorkersPerToken)",
22
+ "toFilter": true
23
+ },
24
+ {
25
+ "name": "value",
26
+ "type": "string",
27
+ "desc": "설정 값 (문자열 저장, 앱에서 파싱·검증)"
28
+ },
29
+ {
30
+ "name": "updated_at",
31
+ "type": "date",
32
+ "nullable": true,
33
+ "desc": "최종 수정일시"
34
+ }
35
+ ],
36
+ "indexes": [
37
+ {
38
+ "type": "unique",
39
+ "name": "settings_key_unique",
40
+ "columns": [
41
+ {
42
+ "name": "key"
43
+ }
44
+ ],
45
+ "using": "btree"
46
+ }
47
+ ],
48
+ "subsets": {
49
+ "A": ["id", "created_at", "key", "value", "updated_at"]
50
+ },
51
+ "enums": {
52
+ "SettingOrderBy": {
53
+ "key-asc": "키순"
54
+ },
55
+ "SettingSearchField": {
56
+ "key": "키"
57
+ }
58
+ }
59
+ }