Compare commits

..
Author SHA1 Message Date
Shivam Mishra a94e252fdc fix(signup): normalize account signup config checks 2026-03-10 14:04:33 +05:30
Shivam Mishra 9e918e0a9f chore: remove dead code 2026-03-10 13:32:47 +05:30
Shivam Mishra f56b0c9f2e fix: coerce nil rollup metric values to zero 2026-03-10 13:22:48 +05:30
Shivam MishraandGitHub c194f10f49 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-10 12:54:44 +05:30
Sojan JoseandGitHub 52cd70dfa3 fix(super-admin): prefill confirmed_at in new user form (#13662)
On self-hosted instances without email configured, users created from
Super Admin can get stuck in an unconfirmed state. This PR implements
the default at the Super Admin frontend form layer, not in backend
creation logic.

What changed:
- Added a custom `ConfirmedAtField` for Super Admin user forms.
- Prefills `confirmed_at` with current time on the **New User** form
(`GET /super_admin/users/new`).
- Kept backend create behavior unchanged
(`resource_class.new(resource_params)`), so API/manual payloads still
behave normally.

Behavior:
- In Super Admin UI, `confirmed_at` is prefilled by default.
- If someone wants an unconfirmed user, they can clear the
`confirmed_at` field before saving.
- If `confirmed_at` is omitted from payload entirely, the created user
remains unconfirmed.

Scope note: external signup flows are intentionally unchanged in this PR
(`/api/v1/accounts`, `/api/v2/accounts`, and social/omniauth signup
behavior are not modified).

## Demo 





https://github.com/user-attachments/assets/436abbb0-d4cf-49a6-a1b8-4b6aa85aa09f
2026-03-10 12:14:58 +05:30
Shivam Mishra 19683fae74 Merge branch 'hotfix/4.11.2' into develop
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
2026-03-09 21:20:08 +05:30
Shivam Mishra 432462f967 feat: harden filter service 2026-03-09 21:19:20 +05:30
Sojan JoseandGitHub 9e40431d3a feat: show MFA status on Super Admin user page (#13724)
This PR adds an MFA row to the individual Super Admin user page and
shows the current state as Enabled or Disabled with a compact status
badge.

Fixes #13723

## Screens

<img width="1370" height="1043" alt="image"
src="https://github.com/user-attachments/assets/b9fee284-43b7-4bbb-9f60-b71ab34b96b7"
/>


<img width="1370" height="1043" alt="image"
src="https://github.com/user-attachments/assets/23c5e6d3-24b8-40d2-9134-0c2b1dc98b41"
/>
2026-03-09 08:04:36 -07:00
Vishnu NarayananandGitHub 4576e75a67 fix: bump redis-client to 0.26.4 to fix Sentinel resolution (#13689)
Description:
  ## Summary

- `redis-client` 0.22.2 uses `.call()` during Sentinel master
resolution, but `redis-rb` 5.x undefines `.call()` (only `.call_v()`
  exists), causing Sentinel connections to fail.
- Bumps `redis-client` from 0.22.2 to 0.26.4 which includes the upstream
fix (redis-rb/redis-client#283).
- Also bumps transitive dependency `connection_pool` from 2.5.3 to
2.5.5.

  Fixes #11665 https://github.com/chatwoot/chatwoot/issues/8368

  ## Test

  - `bundle exec rspec spec/lib/redis/config_spec.rb` passes
  - Full CI suite passes
2026-03-09 20:03:01 +05:30
Shivam MishraandGitHub 63172eb08c Merge branch 'develop' into feat/reporting-events-rollup 2026-03-09 19:32:03 +05:30
Tanmay Deep SharmaandGitHub 11826e2a21 perf: reduce presence update frequency and fix background tab throttling (#13726)
## Description
Reduces the frequency of update_presence WebSocket calls from the live
chat widget and fixes agents appearing offline when the dashboard is in
a background tab.

## Fixes # (issue)
https://github.com/chatwoot/chatwoot/issues/13720

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-09 18:23:44 +05:30
Sivin VargheseandGitHub f4e6aa1bd2 fix: ProseMirror prompt modal UI issue (#13722) 2026-03-09 16:51:49 +05:30
Shivam Mishra 275a76a1a0 fix: preserve 404s for invalid rollup filters 2026-03-09 14:20:27 +05:30
Shivam Mishra 6f127d86b9 feat: add validity gate 2026-03-09 13:57:49 +05:30
Shivam Mishra 014393297b feat: validate timezone 2026-03-09 13:49:21 +05:30
Shivam Mishra 3d67ef02ef chore: disable team rollup read 2026-03-09 13:34:44 +05:30
Shivam Mishra d86c8d1ede style: rubcop issues 2026-03-09 12:55:49 +05:30
Shivam Mishra 8324d84fbf fix: start od week 2026-03-09 12:50:29 +05:30
Shivam Mishra 0bc80bf95f fix: data gaps for missing dates 2026-03-09 12:28:03 +05:30
Shivam Mishra e230e97320 chore: remove compare job 2026-03-09 12:14:03 +05:30
Shivam Mishra 8f331ea7be refactor: split rake task 2026-03-09 11:31:16 +05:30
Shivam Mishra 7feb0ecaef style: fix abc size 2026-03-09 11:20:33 +05:30
Shivam Mishra 5a7c615efa style: simplify output 2026-03-09 11:16:31 +05:30
Shivam MishraandGitHub dcb2198147 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-09 11:07:31 +05:30
Muhsin KelothandGitHub 939471cb3b fix: Prevent duplicate conversations in conversation list (#13713)
Agents using API channel inboxes (e.g., WhatsApp Automate) reported
seeing the same conversation appear twice in their conversation list —
one showing the last message preview and the other showing "No
Messages". Backend investigation confirmed no duplicate conversations
exist in the database, making this purely a frontend issue.

The root cause is a race condition in WebSocket event delivery. When a
conversation is created via the API with auto-assignment, the backend
enqueues multiple ActionCable broadcast jobs (`conversation.created`,
`assignee.changed`, `team.changed`) within milliseconds of each other.
In production with multi-threaded Sidekiq workers, these events can
arrive at the frontend out of order. If `assignee.changed` arrives
before `conversation.created`, the `UPDATE_CONVERSATION` mutation pushes
the conversation into the store (since it doesn't exist yet), and then
`ADD_CONVERSATION` blindly pushes it again — resulting in a duplicate
entry.

The fix adds a uniqueness check in the `ADD_CONVERSATION` mutation to
skip the push if a conversation with the same ID already exists in the
store, matching the dedup pattern already used by
`SET_ALL_CONVERSATION`.
2026-03-06 14:07:02 +04:00
Sivin VargheseandGitHub 88587b1ccb feat: Add natural language date parser for snooze functionality (#13587)
# Pull Request Template

## Description

This PR introduces a custom, lightweight natural-language date parser
(dependency-free except for date-fns) to power snooze actions via the
command bar (e.g., “Remind me tomorrow at 6am”). It also adds support
for multi-language searches.



<details>
  <summary>Supported Formats</summary>

## Snooze Date Parser — Supported Input Formats


## 1. Durations

Specify an amount of time from now.

### Basic

- `5 minutes` · `2 hours` · `3 days` · `1 week` · `6 months` · `ten
year`
- `in 2 hours` · `in 30 minutes` · `in a week` · `in a month`
- `5 minutes from now` · `a week from now` · `two weeks from now`

### Half / fractional

- `half hour` · `half day` · `half week` · `half month`
- `in half a day` · `in half an hour` · `in half a week`
- `one and a half hours` · `in one and a half hours`
- `1.5 hours` · `2.5 days`

### Compound

- `1 hour and 30 minutes` · `2 hours and 15 minutes`
- `2 days at 3pm` · `1 week at 9am`

### Shorthand (no spaces)

- `2h` · `30m` · `1h30m` · `2h15m`
- `1h30minutes` · `2hr15min` · `1hour30min`

### Informal quantities

- `couple hours` · `a couple of days` · `in a couple hours`
- `a few minutes` · `in a few hours` · `in a few days`
- `fortnight` · `in a fortnight` _(= 2 weeks)_

### Trailing "later"

- `2 days later` · `a week later` · `month later`

### Prefix words (`after` / `within`)

- `after 2 hours` · `after 3 days` · `after ten year`
- `within a week` · `within 2 hours`

### Recognised word-numbers

`a` (1) · `an` (1) · `one` – `twenty` · `thirty` · `forty` · `fifty` ·
`sixty` · `ninety` · `half` (0.5) · `couple` (2) · `few` (3)

---

## 2. Relative Days

- `today` · `tonight` · `tomorrow`
- `tomorrow morning` · `tomorrow afternoon` · `tomorrow evening` ·
`tomorrow night`
- `tomorrow at 3pm` · `tomorrow 9` · `tonight at 8` · `tonight at 10pm`
- `tomorrow same time` · `same time tomorrow`
- `day after tomorrow` · `the day after tomorrow` · `day after tomorrow
at 2pm`
- `later today` · `later this afternoon` · `later this evening`

---

## 3. Weekdays

- `monday` · `friday` · `wed` · `thu`
- `friday at 3pm` · `monday 9am` · `wednesday 14:30`
- `monday morning` · `friday afternoon` · `wednesday evening`
- `monday morning 6` · `friday evening 7`
- `this friday` · `upcoming monday` · `coming friday`
- `same time friday` · `same time wednesday`

---

## 4. "Next" Patterns

- `next hour` · `next week` · `next month` · `next year`
- `next week at 2pm` · `next month at 9am`
- `next monday` · `next friday` · `next friday at 3pm`
- `next monday morning` · `next friday evening`
- `monday of next week` · `next week monday`
- `next january` · `next december`
- `next business day` · `next working day`

---

## 5. Time of Day

- `morning` · `afternoon` · `evening` · `night` · `noon` · `midnight`
- `this morning` · `this afternoon` · `this evening`
- `early morning` · `late evening` · `late night`
- `morning at 8am` · `evening 6pm` · `afternoon 2pm`
- `eod` · `end of day` · `end of the day`

---

## 6. Standalone Time

- **12-hour:** `3pm` · `9am` · `at 3pm` · `at 9:30am`
- **24-hour:** `14:30` · `at 14:30`

---

## 7. Named Dates (Month + Day)

- `jan 15` · `january 15` · `march 20` · `dec 25`
- `jan 1st` · `march 3rd` · `april 2nd` · `december 31st`
- `15 march` · `25 dec` _(reversed order)_
- `jan 15 2025` · `dec 25 2025` · `march 20 next year`
- `jan 15 at 2pm` · `march 5 at 2pm`
- `december 2025` · `january 2024` _(month + year only)_

---

## 8. Month + Ordinal Patterns

Target a specific week or day within a month.

### Week of month

- `april first week` · `july 2nd week` · `feb 3rd week`
- `first week of april` · `2nd week of july`

### Day of month

- `april first day` · `march second day` · `march 5th day`
- `third day of march` · `5th day of jan at 2pm`

### Supported ordinals

- **Digit:** `1st` `2nd` `3rd` `4th` `5th` … (up to 31 for days, 5 for
weeks)
- **Word:** `first` `second` `third` `fourth` `fifth` `sixth` `seventh`
`eighth` `ninth` `tenth`

---

## 9. Formal / Numeric Dates

- **ISO:** `2025-01-15`
- **Slash (M/D/Y):** `01/15/2025`
- **Dash (D-M-Y):** `15-01-2025`
- **Dot (D.M.Y):** `15.01.2025`
- Any of the above **+ time:** `2025-01-15 at 3pm`

---

## 10. Special Phrases

- `this weekend` · `weekend` · `next weekend`
- `end of week` · `end of month`
- `end of next week` · `end of next month`
- `beginning of next week` · `start of next week`
- `beginning of next month`

---

## 11. Noise / Filler Stripping

The parser silently removes conversational prefixes so all of these work
exactly the same as the bare expression:

```
snooze for 2 hours          →  2 hours
remind me tomorrow          →  tomorrow
please snooze until friday  →  friday
can you set a reminder for next week  →  next week
schedule this for jan 15    →  jan 15
postpone to next monday     →  next monday
defer for 2 days            →  2 days
delay it by 1 hour          →  1 hour
```

### Recognised filler verbs / prefixes

`snooze` · `remind` · `remind me` · `set a reminder` · `add a reminder`
·
`schedule` · `postpone` · `defer` · `delay` · `push`

### Recognised prepositions (stripped)

`on` · `to` · `for` · `at` · `until` · `till` · `by` · `from` · `after`
· `within`

### Typo corrections

`tommorow` / `tommorrow` → `tomorrow` · `nxt` → `next`

---

## 12. Multi-Language Support

The parser supports localised input via translations in `snooze.json`.

### Translatable token categories

- **Units:** minute, hour, day, week, month, year _(singular + plural)_
- **Relative days:** tomorrow, day after tomorrow, next week / month,
this / next weekend
- **Time of day:** morning, afternoon, evening, night, noon, midnight
- **Word numbers:** one through ten, twelve, fifteen, twenty, thirty
- **Ordinals:** first through fifth
- **Structural words:** at, in, of, after, week, day, from now, next
year
- **Meridiem:** am, pm

### Auto-detected from locale

Weekday names and month names are resolved automatically via
`Intl.DateTimeFormat` for the user's locale — no manual translation
needed.

</details>

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

**Screenshots**
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/c690d328-a0df-41d2-b531-2b4e6ce6b5fd"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/fa881acc-4fed-4ba3-9166-58bd953bcb26"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/4d9a224b-641c-409c-a7ce-3dec2b5355e2"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/465b9835-d82c-4bc7-a2ae-94976ada2d3b"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/839fe8fc-8943-4b66-83ca-5c61c95f24d8"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/3a9a54f2-7669-40f2-b098-a3f5c183526d"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/7791ab2b-c763-49a9-90a0-e91b0d8f0a26"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/4689390c-0e7f-48ae-acc7-d8e28695452f"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/d0aa5217-d0e1-4f41-b663-72888d028a3a"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/4fa9ff5b-a874-43d5-812f-6abe1a95a5ac"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/2c8199a6-f872-46af-986f-bdf8597248f5"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/5bd9effc-7518-4f96-b2f2-7c547f32f500"
/>




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-06 12:20:22 +04:00
Shivam MishraandGitHub d0f07c8f5f Merge branch 'develop' into feat/reporting-events-rollup 2026-03-05 20:39:27 +05:30
Shivam MishraandGitHub a7274bc8e8 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-05 15:14:15 +05:30
Shivam Mishra 7c6097e4c8 feat: add task to set timezone for reporting events 2026-03-05 15:00:05 +05:30
Shivam Mishra 35d936ae04 chore: cleaner output for rake 2026-03-05 14:54:10 +05:30
Shivam Mishra 65ccf8bae2 feat: add plan 2026-03-05 14:51:07 +05:30
Shivam Mishra 0d27e76c3f feat: simplify backfill 2026-03-05 14:48:40 +05:30
Shivam Mishra 8c98b619bd feat: allow timezone offset for summary reports 2026-03-05 14:34:03 +05:30
Shivam Mishra 47fc8072ad feat: allow rollup path for inbox 2026-03-05 14:34:03 +05:30
Shivam Mishra dfd92acd4a feat: don't allow without timezone 2026-03-05 14:34:03 +05:30
Shivam MishraandGitHub ea5a46ee0c Merge branch 'develop' into feat/reporting-events-rollup 2026-03-05 14:11:45 +05:30
Shivam Mishra 9534d7157d fix: pass summary dimension type to rollup-backed builders 2026-03-03 18:11:42 +05:30
Shivam MishraandGitHub 51f298c129 Merge branch 'develop' into feat/reporting-events-rollup 2026-03-03 17:40:59 +05:30
Shivam MishraandGitHub 2e18cb3b15 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-24 20:24:15 +05:30
Shivam MishraandGitHub c8637bf0de Merge branch 'develop' into feat/reporting-events-rollup 2026-02-24 16:07:37 +05:30
Shivam MishraandGitHub a15dffeadb Merge branch 'develop' into feat/reporting-events-rollup 2026-02-23 17:55:15 +05:30
Shivam Mishra a08125e283 Merge branch 'hotfix/4.11.1'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-02-20 20:02:18 +05:30
Shivam Mishra 22ba4a6dfe Merge branch 'feat/reporting-events-rollup' of github.com:chatwoot/chatwoot into feat/reporting-events-rollup 2026-02-18 18:22:39 +05:30
Shivam Mishra e840419029 refactor: extract backfill rake task into a class
Reduces block length by moving logic from the rake task block
into a dedicated ReportingEventsRollupBackfill class with
focused methods.
2026-02-18 18:22:18 +05:30
Shivam Mishra b56765d9ba style: split migration into smaller methods to reduce method length 2026-02-18 18:22:11 +05:30
Shivam Mishra 63dce7778e style: fix rubocop offenses in timeseries report builders
Remove duplicate branches, multiline block chains, and reduce
method complexity in average and count report builders.
2026-02-18 18:22:03 +05:30
Shivam Mishra 8e74ee6716 style: extract methods from upsert_rollup to reduce method length 2026-02-18 18:21:55 +05:30
Shivam Mishra 325afc5ab1 style: replace case-when with hash lookup in dimension_type_to_rollup 2026-02-18 18:21:49 +05:30
Shivam MishraandGitHub addc179c01 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-18 18:18:52 +05:30
Shivam Mishra 4b9b1ed7c6 fix: DST spec 2026-02-18 18:16:08 +05:30
Shivam Mishra 4992cb379a refactor: remove redundant scan 2026-02-18 18:12:32 +05:30
Shivam Mishra 98035d5f62 style: rspec name 2026-02-18 18:12:16 +05:30
Shivam Mishra 164b916670 style: rubocop issues in rake task 2026-02-18 18:10:46 +05:30
Shivam Mishra 308accab6d style: rubocop issues 2026-02-18 16:13:13 +05:30
Shivam Mishra 254271a37a style: rubocop autofix changes 2026-02-18 16:05:57 +05:30
Shivam MishraandGitHub b339bf128a Merge branch 'develop' into feat/reporting-events-rollup 2026-02-17 14:47:25 +05:30
Shivam Mishra 0313cd87c2 fix: emit zero-value periods in rollup count timeseries
The rollup path only emitted periods with data, while the raw path
uses group_by_period with default_value: 0 to include inactive periods.
This also reads group_by from params instead of calling a method that
summary builders don't implement.
2026-02-13 15:21:26 +05:30
Shivam Mishra eb08245b3d fix: pass epoch timestamps to comparison builders
The comparison rake task was passing YYYY-MM-DD strings as since/until,
but DateRangeHelper parses those fields as Unix seconds via
DateTime.strptime(value, '%s'), causing validation to run against
the wrong time window.
2026-02-13 13:24:45 +05:30
Shivam Mishra d5d9d3ad7d fix: exclude until boundary from rollup date range
DateRangeHelper#range builds an exclusive range (since...until), but
rollup_date_range was converting range.last directly to a date, causing
an extra day to be included when until falls at midnight.
2026-02-13 13:22:00 +05:30
Shivam Mishra 17f5112015 fix: skip per-metric rollup check in summary builders
Summary builders fetch all metrics in a single query and don't use
params[:metric], so metric_covered? was always returning false and
preventing rollup usage for summary reports.
2026-02-13 12:44:03 +05:30
Shivam Mishra 649f58a57a chore: update rake task to better find correct IANA timzone 2026-02-13 12:39:05 +05:30
Shivam Mishra 69a75e538e feat: add some implementation notes 2026-02-13 12:38:45 +05:30
Shivam Mishra 4f124f64d4 chore: remove use_rollup override 2026-02-13 12:38:35 +05:30
Shivam MishraandGitHub f24b931527 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-12 12:14:01 +05:30
Shivam Mishra b6a84af227 fix: Address three PR review bugs in rollup system
Fix metric_covered? NoMethodError when metric param is nil - summary
builders don't pass a metric param, so add nil guard before calling
to_sym.

Fix business_hours not respected in rollup paths - rollup queries were
always using sum_value instead of switching to sum_value_business_hours
when business_hours param is true. Add rollup_value_column helper to
RollupConditions and use it across all rollup read paths.

Fix date range timezone mismatch in rollup queries - rollup date column
stores account-local dates but queries filtered with UTC DateTime
boundaries from DateRangeHelper. Add rollup_date_range helper that
converts UTC range to account-local dates and use it across all rollup
query paths.
2026-02-12 12:12:07 +05:30
Shivam MishraandGitHub 2380218b05 Merge branch 'develop' into feat/reporting-events-rollup 2026-02-11 22:55:11 +05:30
Shivam Mishra af59acd6a9 docs: Add TODO to remove use_rollup override before merge
Add TODO comment noting that the use_rollup parameter override is only
for validation purposes via the comparison rake task and should be
removed before merging to production.
2026-02-11 22:52:12 +05:30
Shivam Mishra f29f9517d8 refactor: Extend comparison period from 6 months to 1 year
Increase comparison period to 1 year for more comprehensive validation.
Will now compare ~365 daily summaries instead of ~180 days.
2026-02-11 22:47:21 +05:30
Shivam Mishra ab013b9e2b refactor: Change comparison from weekly to daily granularity
Update comparison rake task to compare daily summaries instead of weekly.
Provides more granular comparison (180+ days vs 26 weeks for 6 months) and
better detection of specific date discrepancies. Progress now shows day-by-day
comparison with date in output.
2026-02-11 22:45:11 +05:30
Shivam Mishra 9a17ae6a76 refactor: Add use_rollup parameter to base summary builder
Add explicit use_rollup parameter to BaseSummaryBuilder that can override
the automatic rollup detection logic. This allows the comparison rake task
to explicitly control whether to use rollup or raw data without toggling
feature flags. Cleaner approach that avoids database writes during comparison.
2026-02-11 22:43:12 +05:30
Shivam Mishra d0713408a9 feat: Add comparison rake task to validate rollup accuracy
Add interactive rake task that compares rollup data vs raw events data for
summary reports. Generates weekly summaries for past 6 months across agent,
inbox, and team dimensions. Compares metrics (conversations count, resolution
time, first response time, reply time) and reports any mismatches. Useful
for validating rollup data accuracy after backfill.
2026-02-11 22:39:10 +05:30
Shivam Mishra dcfb1599c8 feat: Add interactive backfill rake task for reporting rollups
Add BackfillService and rake task to backfill historical reporting events
into the rollup table. Service uses delete-first idempotency strategy with
hybrid SQL+Ruby aggregation. Rake task provides interactive prompts for
account ID, timezone, date range override, dry run, and confirmation before
proceeding. Includes live progress tracking and fail-fast error handling.
2026-02-11 22:28:19 +05:30
Shivam Mishra de0d6ca8a5 test: add unit tests for RollupConditions concern 2026-02-11 21:56:03 +05:30
Shivam Mishra a89cdd57cf test: add unit tests for RollupService 2026-02-11 21:55:57 +05:30
Shivam Mishra 67abd78568 test: add unit tests for ReportingEventsRollup model 2026-02-11 21:55:51 +05:30
Shivam Mishra d28f11e119 test: add factory for ReportingEventsRollup 2026-02-11 21:55:44 +05:30
Shivam Mishra d080407a66 refactor: update report builders to use ReportingEventsRollup model 2026-02-11 21:55:35 +05:30
Shivam Mishra 2234a6707c fix: resolve ambiguous column references in rollup upsert SQL 2026-02-11 21:55:29 +05:30
Shivam Mishra be540a08ec fix: add concurrent index creation to reporting events rollup migration 2026-02-11 21:55:21 +05:30
Shivam Mishra 4f53d32924 feat: Add reporting events rollup feature flag 2026-02-11 21:28:51 +05:30
Shivam Mishra 078d880a0e feat: Add reporting timezone to account settings 2026-02-11 21:28:29 +05:30
Shivam Mishra 46ab6c3946 feat: Add rollup read path to report builders 2026-02-11 21:28:17 +05:30
Shivam Mishra 0e35067bc8 feat: Add rollup conditions concern for report builders 2026-02-11 21:28:11 +05:30
Shivam Mishra 8474295a5d feat: Add rollup service for writing aggregated metrics 2026-02-11 21:28:07 +05:30
Shivam Mishra 1fa461327a feat: Add reporting events rollup database migration and model 2026-02-11 21:24:47 +05:30
80 changed files with 6734 additions and 197 deletions
+2 -2
View File
@@ -191,7 +191,7 @@ GEM
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
@@ -736,7 +736,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.22.2)
redis-client (0.26.4)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
+1 -1
View File
@@ -1 +1 @@
4.11.1
4.11.2
+1
View File
@@ -1,6 +1,7 @@
class V2::ReportBuilder
include DateRangeHelper
include ReportHelper
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
@@ -1,5 +1,6 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
include V2::Reports::Concerns::RollupConditions
def build
load_data
@@ -8,9 +9,15 @@ class V2::Reports::BaseSummaryBuilder
private
# Summary builders fetch all metrics in a single query, so the per-metric
# check from RollupConditions does not apply.
def metric_covered?
true
end
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
use_rollup? ? load_rollup_data : load_reporting_events_data
end
def load_reporting_events_data
@@ -34,6 +41,46 @@ class V2::Reports::BaseSummaryBuilder
@avg_reply_time = results.transform_values(&:avg_reply_time)
end
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def load_rollup_data
dimension_type = dimension_type_to_rollup
value_col = rollup_value_column
# Fetch rollup data grouped by dimension_id
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
dimension_type: dimension_type,
date: rollup_date_range
).group(:dimension_id).select(
'dimension_id',
'SUM(CASE WHEN metric = \'resolutions_count\' THEN count ELSE 0 END) as resolved_count',
"SUM(CASE WHEN metric = 'resolution_time' THEN count ELSE 0 END) as resolution_count",
"SUM(CASE WHEN metric = 'resolution_time' THEN #{value_col} ELSE 0 END) as resolution_sum_value",
"SUM(CASE WHEN metric = 'first_response' THEN count ELSE 0 END) as first_response_count",
"SUM(CASE WHEN metric = 'first_response' THEN #{value_col} ELSE 0 END) as first_response_sum_value",
"SUM(CASE WHEN metric = 'reply_time' THEN count ELSE 0 END) as reply_count",
"SUM(CASE WHEN metric = 'reply_time' THEN #{value_col} ELSE 0 END) as reply_sum_value"
)
# Convert to the expected format
results = {}
rollup_rows.each do |row|
results[row.dimension_id] = row
end
@resolved_count = results.transform_values { |r| r.resolved_count.to_i }
@avg_resolution_time = results.transform_values do |r|
r.resolution_count.to_i.zero? ? nil : (r.resolution_sum_value.to_f / r.resolution_count.to_i)
end
@avg_first_response_time = results.transform_values do |r|
r.first_response_count.to_i.zero? ? nil : (r.first_response_sum_value.to_f / r.first_response_count.to_i)
end
@avg_reply_time = results.transform_values do |r|
r.reply_count.to_i.zero? ? nil : (r.reply_sum_value.to_f / r.reply_count.to_i)
end
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
end
@@ -0,0 +1,75 @@
module V2::Reports::Concerns::RollupConditions
COVERED_METRICS = {
avg_first_response_time: :first_response,
avg_resolution_time: :resolution_time,
reply_time: :reply_time,
resolutions_count: :resolutions_count,
bot_resolutions_count: :bot_resolutions_count,
bot_handoffs_count: :bot_handoffs_count
}.freeze
SUPPORTED_DIMENSIONS = %w[account agent inbox].freeze
def use_rollup?
# Condition 0: reporting_timezone must be configured on account
return false if account.reporting_timezone.blank?
# Condition 1: Feature flag must be enabled
return false unless account.feature_enabled?('reporting_events_rollup')
# Condition 2: Metric must be covered by rollup
return false unless metric_covered?
# Condition 3: Not hourly granularity
return false if params[:group_by] == 'hour'
# Condition 4: Dimension must be supported
return false unless dimension_supported?
# Condition 5: Timezone must match
timezone_matches_account?
end
private
def metric_covered?
return false if params[:metric].blank?
COVERED_METRICS.key?(params[:metric].to_sym)
end
def dimension_supported?
return true if params[:type].blank? # account dimension (no specific ID)
SUPPORTED_DIMENSIONS.include?(params[:type].to_s)
end
def timezone_matches_account?
return false if params[:timezone_offset].blank?
offset_in_seconds = params[:timezone_offset].to_f * 3600
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
account_zone&.now&.utc_offset == offset_in_seconds
end
def metric_to_rollup_metric(metric)
COVERED_METRICS[metric.to_sym]
end
def rollup_value_column
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :sum_value_business_hours : :sum_value
end
def rollup_date_range
tz = ActiveSupport::TimeZone[account.reporting_timezone]
start_date = range.first.in_time_zone(tz).to_date
# range is exclusive (since...until), so subtract a second to avoid
# including the until boundary day when it falls exactly at midnight.
end_date = (range.last - 1.second).in_time_zone(tz).to_date
start_date..end_date
end
def dimension_type_to_rollup
{ 'account' => :account, 'agent' => :agent, 'inbox' => :inbox }[params[:type].to_s]
end
end
@@ -11,11 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :conversations_count, :resolved_count,
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
def load_data
@conversations_count = fetch_conversations_count
load_reporting_events_data
end
def fetch_conversations_count
account.conversations.where(created_at: range).group(group_by_key).count
end
@@ -1,5 +1,15 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
use_rollup? ? rollup_timeseries : raw_timeseries
end
def aggregate_value
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
end
private
def raw_timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
@@ -12,11 +22,98 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
end
end
def aggregate_value
def rollup_timeseries
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
metric = metric_to_rollup_metric(params[:metric])
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
)
group_and_aggregate_rollup(rollup_rows)
end
def raw_aggregate_value
object_scope.average(average_value_key)
end
private
def rollup_aggregate_value
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
metric = metric_to_rollup_metric(params[:metric])
value_col = rollup_value_column
result = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
).pick(Arel.sql("SUM(count), SUM(#{value_col})"))
return nil if result.blank? || result[0].to_i.zero?
result[1].to_f / result[0].to_i
end
def dimension_id_for_rollup
params[:type].to_s == 'account' ? account.id : scope.id
end
def group_and_aggregate_rollup(rollup_rows)
grouped_data = aggregate_rollup_rows(rollup_rows, all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } })
results = grouped_data.map do |date_key, data|
value = data[:count].zero? ? 0 : data[:sum_value] / data[:count]
{ value: value, timestamp: date_key.in_time_zone(timezone).to_i, count: data[:count] }
end
results.sort_by { |h| h[:timestamp] }
end
def aggregate_rollup_rows(rollup_rows, grouped_data = {})
value_col = rollup_value_column
rollup_rows.each_with_object(grouped_data) do |row, all_grouped_data|
date_key = date_key_for_group(row.date)
all_grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
all_grouped_data[date_key][:count] += row.count
all_grouped_data[date_key][:sum_value] += row.public_send(value_col)
end
end
def date_key_for_group(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def all_periods_in_range
date_range = rollup_date_range
dates = []
current = date_key_for_group(date_range.first)
while current <= date_range.last
dates << current
current = advance_period(current)
end
dates
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
def event_name
metric_to_event_name = {
@@ -1,6 +1,8 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
include V2::Reports::Concerns::RollupConditions
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
@@ -1,5 +1,15 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
use_rollup? ? rollup_timeseries : raw_timeseries
end
def aggregate_value
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
end
private
def raw_timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
@@ -11,11 +21,92 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas
end
end
def aggregate_value
def rollup_timeseries
metric = metric_to_rollup_metric(params[:metric])
return [] if metric.blank?
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
)
group_and_aggregate_rollup_counts(rollup_rows)
end
def raw_aggregate_value
object_scope.count
end
private
def rollup_aggregate_value
metric = metric_to_rollup_metric(params[:metric])
return 0 if metric.blank?
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
).sum(:count).to_i
end
def dimension_id_for_rollup
params[:type].to_s == 'account' ? account.id : scope.id
end
def group_and_aggregate_rollup_counts(rollup_rows)
# Pre-fill all periods with zero to match raw path's default_value: 0 behavior
grouped_data = all_periods_in_range.index_with { 0 }
rollup_rows.each do |row|
date_key = date_key_for_period(row.date)
grouped_data[date_key] ||= 0
grouped_data[date_key] += row.count
end
results = grouped_data.map do |date_key, count|
{ value: count, timestamp: date_key.in_time_zone(timezone).to_i }
end
results.sort_by { |h| h[:timestamp] }
end
def date_key_for_period(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def all_periods_in_range
date_range = rollup_date_range
dates = []
current = date_key_for_period(date_range.first)
while current <= date_range.last
dates << current
current = advance_period(current)
end
dates
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
def metric
@metric ||= params[:metric]
@@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def validate_captcha
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
end
def team
render_report_with(V2::Reports::TeamSummaryBuilder)
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
end
def inbox
render_report_with(V2::Reports::InboxSummaryBuilder)
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
end
def label
@@ -36,15 +36,17 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
until: permitted_params[:until],
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
}
@builder_params[:timezone_offset] = permitted_params[:timezone_offset] if permitted_params[:timezone_offset].present?
end
def render_report_with(builder_class)
builder = builder_class.new(account: Current.account, params: @builder_params)
def render_report_with(builder_class, type: nil)
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
builder = builder_class.new(account: Current.account, params: builder_params)
render json: builder.build
end
def permitted_params
params.permit(:since, :until, :business_hours)
params.permit(:since, :until, :business_hours, :timezone_offset)
end
def date_range_too_long?
@@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def validate_captcha
@@ -51,8 +51,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def account_signup_allowed?
# set it to true by default, this is the behaviour across the app
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
GlobalConfigService.account_signup_enabled?
end
def resource_class(_mapping = nil)
+1 -1
View File
@@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard
current_sign_in_ip: Field::String,
last_sign_in_ip: Field::String,
confirmation_token: Field::String,
confirmed_at: Field::DateTime,
confirmed_at: ConfirmedAtField,
confirmation_sent_at: Field::DateTime,
unconfirmed_email: Field::String,
name: Field::String.with_options(searchable: true),
+4
View File
@@ -0,0 +1,4 @@
require 'administrate/field/base'
class ConfirmedAtField < Administrate::Field::DateTime
end
+9 -5
View File
@@ -47,11 +47,15 @@ module Filters::FilterHelper
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive'
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
query_hash[:attribute_key]]
)
else
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
"#{filter_operator_value} #{query_hash[:query_operator]} "
ActiveRecord::Base.sanitize_sql_array(
["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
query_hash[:attribute_key]]
)
end
end
@@ -70,7 +74,7 @@ module Filters::FilterHelper
def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_case_insensitive_filter(query_hash, filter_operator_value)
@@ -1,6 +1,8 @@
/* global axios */
import ApiClient from './ApiClient';
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
class SummaryReportsAPI extends ApiClient {
constructor() {
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
@@ -12,6 +14,7 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -22,6 +25,7 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -32,6 +36,7 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -42,6 +47,7 @@ class SummaryReportsAPI extends ApiClient {
since,
until,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
@@ -1,4 +1,5 @@
<script setup>
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
@@ -28,7 +29,11 @@ const handleButtonClick = () => {
{{ headerTitle }}
</span>
<div
v-on-clickaway="() => emit('close')"
v-on-click-outside="[
() => emit('close'),
// This will prevent closing the modal when the editor Create link popup is open
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative group/campaign-button"
>
<Button
@@ -277,7 +277,7 @@ useKeyboardEvents(keyboardEvents);
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['div.ProseMirror-prompt'] },
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative"
:class="{
@@ -96,6 +96,17 @@ const close = () => {
isOpen.value = false;
};
// Only close if the close event originated from this dialog,
// not from a child dialog (e.g. ProseMirror prompt) bubbling up.
const handleDialogClose = e => e.target === dialogRef.value && close();
// Only close on click-outside if this dialog is the topmost one.
// If another dialog (e.g. ProseMirror prompt) is open on top, ignore.
const handleClickOutside = () => {
const dialogs = document.querySelectorAll('dialog[open]');
if (dialogs[dialogs.length - 1] === dialogRef.value) close();
};
const confirm = () => {
emit('confirm');
};
@@ -113,9 +124,9 @@ defineExpose({ open, close });
positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]"
@close="close"
@close.prevent="handleDialogClose"
>
<OnClickOutside @trigger="close">
<OnClickOutside @trigger="handleClickOutside">
<form
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@@ -978,11 +978,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
}
.ProseMirror-prompt-backdrop::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
@apply bg-n-alpha-3 border border-n-strong p-6 shadow-xl rounded-xl w-96 !important;
h5 {
@apply text-n-slate-12 mb-1.5;
@apply text-n-slate-12 mb-3;
}
.ProseMirror-prompt-buttons {
@@ -326,26 +326,4 @@ export default {
max-height: 7.5rem;
overflow: auto;
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
</style>
@@ -100,6 +100,8 @@ export default {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.updateConversations('snoozed', snoozeType);
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
@@ -119,6 +119,10 @@ export const COPILOT_EVENTS = Object.freeze({
USE_CAPTAIN_RESPONSE: 'Copilot: Used captain response',
});
export const SNOOZE_EVENTS = Object.freeze({
NLP_SNOOZE_APPLIED: 'Applied snooze via text-to-date input',
});
export const GENERAL_EVENTS = Object.freeze({
COMMAND_BAR: 'Used commandbar',
});
@@ -0,0 +1,12 @@
/**
* snoozeDateParser — Natural language date/time parser for snooze.
*
* Barrel re-export from submodules:
* - parser.js: core parsing engine (parseDateFromText)
* - localization.js: multilingual suggestion generator (generateDateSuggestions)
* - suggestions.js: compositional suggestion engine
* - tokenMaps.js: shared token maps and utility functions
*/
export { parseDateFromText } from './parser';
export { generateDateSuggestions } from './localization';
@@ -0,0 +1,408 @@
/**
* Handles non-English input and generates the final suggestion list.
* Translates localized words to English before parsing, then converts
* suggestion labels back to the user's language for display.
*/
import {
WEEKDAY_MAP,
MONTH_MAP,
UNIT_MAP,
WORD_NUMBER_MAP,
RELATIVE_DAY_MAP,
TIME_OF_DAY_MAP,
sanitize,
stripNoise,
normalizeDigits,
} from './tokenMaps';
import { parseDateFromText } from './parser';
import { buildSuggestionCandidates, MAX_SUGGESTIONS } from './suggestions';
// ─── English Reference Data ─────────────────────────────────────────────────
const EN_WEEKDAYS_LIST = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
];
const EN_MONTHS_LIST = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
];
const EN_DEFAULTS = {
UNITS: {
MINUTE: 'minute',
MINUTES: 'minutes',
HOUR: 'hour',
HOURS: 'hours',
DAY: 'day',
DAYS: 'days',
WEEK: 'week',
WEEKS: 'weeks',
MONTH: 'month',
MONTHS: 'months',
YEAR: 'year',
YEARS: 'years',
},
RELATIVE: {
TOMORROW: 'tomorrow',
DAY_AFTER_TOMORROW: 'day after tomorrow',
NEXT_WEEK: 'next week',
NEXT_MONTH: 'next month',
THIS_WEEKEND: 'this weekend',
NEXT_WEEKEND: 'next weekend',
},
TIME_OF_DAY: {
MORNING: 'morning',
AFTERNOON: 'afternoon',
EVENING: 'evening',
NIGHT: 'night',
NOON: 'noon',
MIDNIGHT: 'midnight',
},
WORD_NUMBERS: {
ONE: 'one',
TWO: 'two',
THREE: 'three',
FOUR: 'four',
FIVE: 'five',
SIX: 'six',
SEVEN: 'seven',
EIGHT: 'eight',
NINE: 'nine',
TEN: 'ten',
TWELVE: 'twelve',
FIFTEEN: 'fifteen',
TWENTY: 'twenty',
THIRTY: 'thirty',
},
ORDINALS: {
FIRST: 'first',
SECOND: 'second',
THIRD: 'third',
FOURTH: 'fourth',
FIFTH: 'fifth',
},
MERIDIEM: { AM: 'am', PM: 'pm' },
HALF: 'half',
NEXT: 'next',
THIS: 'this',
AT: 'at',
IN: 'in',
OF: 'of',
AFTER: 'after',
WEEK: 'week',
DAY: 'day',
FROM_NOW: 'from now',
NEXT_YEAR: 'next year',
};
const STRUCTURAL_WORDS = [
'at',
'in',
'next',
'this',
'from',
'now',
'after',
'half',
'same',
'time',
'weekend',
'end',
'of',
'the',
'eod',
'am',
'pm',
'week',
'day',
'first',
'second',
'third',
'fourth',
'fifth',
];
const ENGLISH_VOCAB = new Set([
...Object.keys(WEEKDAY_MAP),
...Object.keys(MONTH_MAP),
...Object.keys(UNIT_MAP),
...Object.keys(WORD_NUMBER_MAP),
...Object.keys(RELATIVE_DAY_MAP),
...Object.keys(TIME_OF_DAY_MAP),
...EN_WEEKDAYS_LIST,
...EN_MONTHS_LIST,
...STRUCTURAL_WORDS,
]);
// ─── Regex for token replacement ────────────────────────────────────────────
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const MONTH_NAME_RE = new RegExp(`\\b(?:${MONTH_NAMES})\\b`, 'i');
const NUM_TOD_RE =
/\b(\d{1,2}(?::\d{2})?)\s+(morning|noon|afternoon|evening|night)\b/g;
const TOD_TO_MERIDIEM = {
morning: 'am',
noon: 'pm',
afternoon: 'pm',
evening: 'pm',
night: 'pm',
};
// ─── Translation Cache ──────────────────────────────────────────────────────
const safeString = v => (v == null ? '' : String(v));
const MAX_PAIRS_CACHE = 20;
const pairsCache = new Map();
const CACHE_SECTIONS = [
'UNITS',
'RELATIVE',
'TIME_OF_DAY',
'WORD_NUMBERS',
'ORDINALS',
'MERIDIEM',
];
const SINGLE_KEYS = [
'HALF',
'NEXT',
'THIS',
'AT',
'IN',
'OF',
'AFTER',
'WEEK',
'DAY',
'FROM_NOW',
'NEXT_YEAR',
];
/** Create a string key from translations so we can cache results. */
const translationSignature = translations => {
if (!translations || typeof translations !== 'object') return 'none';
return [
...CACHE_SECTIONS.flatMap(section => {
const values = translations[section] || {};
return Object.keys(values)
.sort()
.map(k => `${section}.${k}:${safeString(values[k]).toLowerCase()}`);
}),
...SINGLE_KEYS.map(
k => `${k}:${safeString(translations[k]).toLowerCase()}`
),
].join('|');
};
/** Build a list of [localWord, englishWord] pairs from the translations and browser locale. */
const buildReplacementPairsUncached = (translations, locale) => {
const pairs = [];
const seen = new Set();
const t = translations || {};
const addPair = (local, en) => {
const l = sanitize(safeString(local));
const e = safeString(en).toLowerCase();
const key = `${l}\0${e}`;
if (l && e && l !== e && !seen.has(key)) {
seen.add(key);
pairs.push([l, e]);
}
};
CACHE_SECTIONS.forEach(section => {
const localSection = t[section] || {};
const enSection = EN_DEFAULTS[section] || {};
Object.keys(enSection).forEach(key => {
addPair(localSection[key], enSection[key]);
});
});
SINGLE_KEYS.forEach(key => addPair(t[key], EN_DEFAULTS[key]));
try {
const wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' });
// Jan 1, 2024 is a Monday — aligns with EN_WEEKDAYS_LIST[0]='monday'
EN_WEEKDAYS_LIST.forEach((en, i) => {
addPair(wdFmt.format(new Date(2024, 0, i + 1)), en);
});
} catch {
/* locale not supported */
}
try {
const moFmt = new Intl.DateTimeFormat(locale, { month: 'long' });
EN_MONTHS_LIST.forEach((en, i) => {
addPair(moFmt.format(new Date(2024, i, 1)), en);
});
} catch {
/* locale not supported */
}
pairs.sort((a, b) => b[0].length - a[0].length);
return pairs;
};
/** Same as above but cached. Keeps up to 20 entries to avoid rebuilding every call. */
const buildReplacementPairs = (translations, locale) => {
const cacheKey = `${locale || ''}:${translationSignature(translations)}`;
if (pairsCache.has(cacheKey)) return pairsCache.get(cacheKey);
const pairs = buildReplacementPairsUncached(translations, locale);
if (pairsCache.size >= MAX_PAIRS_CACHE)
pairsCache.delete(pairsCache.keys().next().value);
pairsCache.set(cacheKey, pairs);
return pairs;
};
// ─── Token Replacement ──────────────────────────────────────────────────────
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Swap localized words for their English versions in the text. */
const substituteLocalTokens = (text, pairs) => {
let r = text;
pairs.forEach(([local, en]) => {
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en);
});
return r;
};
/** Drop any words the parser wouldn't understand (keeps English words and numbers). */
const filterToEnglishVocab = text =>
normalizeDigits(text)
.replace(/(\d+)h\b/g, '$1:00')
.split(/\s+/)
.filter(w => /[\d:]/.test(w) || ENGLISH_VOCAB.has(w.toLowerCase()))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
/** Move "next year" to the right spot so the parser can read it (after the month, before time). */
const repositionNextYear = text => {
if (!MONTH_NAME_RE.test(text)) return text;
let r = text.replace(/\b(?:next\s+)?year\b/i, m =>
/next/i.test(m) ? m : 'next year'
);
if (!/\bnext\s+year\b/i.test(r)) return r;
const withoutNY = r.replace(/\bnext\s+year\b/i, '').trim();
const timeRe = /(?:(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s*$/i;
const timePart = withoutNY.match(timeRe);
if (timePart) {
const beforeTime = withoutNY.slice(0, timePart.index).trim();
r = `${beforeTime} next year ${timePart[0].trim()}`;
} else {
r = `${withoutNY} next year`;
}
return r;
};
/** Run the full translation pipeline: swap tokens, filter, fix am/pm, reposition "next year". */
const replaceTokens = (text, pairs) => {
const substituted = substituteLocalTokens(text, pairs);
const filtered = filterToEnglishVocab(substituted);
const fixed = filtered.replace(
NUM_TOD_RE,
(_, t, tod) => `${t}${TOD_TO_MERIDIEM[tod]}`
);
return stripNoise(repositionNextYear(fixed));
};
/** Convert English words back to the user's language for display. */
const reverseTokens = (text, pairs) =>
pairs.reduce(
(r, [local, en]) =>
r.replace(
new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'),
local
),
text
);
// ─── Main Suggestion Generator ──────────────────────────────────────────────
/**
* Generate snooze suggestions from what the user has typed so far.
* Works with any language if translations are provided. Returns up to 5
* unique results, each with a label, date, and unix timestamp.
*
* @param {string} text - what the user typed
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
* @param {{ translations?: object, locale?: string }} [options] - i18n config
* @returns {Array<{ label: string, date: Date, unix: number }>}
*/
export const generateDateSuggestions = (
text,
referenceDate = new Date(),
{ translations, locale } = {}
) => {
if (!text || typeof text !== 'string') return [];
const normalized = sanitize(text);
if (!normalized) return [];
const stripped = stripNoise(normalized);
const pairs =
locale && locale !== 'en'
? buildReplacementPairs(translations, locale)
: [];
// Try English parse first, then translated parse if we have locale pairs.
// This avoids the problem where a single overlapping word (e.g. "in" in German)
// would skip token translation entirely.
const directParse = parseDateFromText(stripped, referenceDate);
const translated = pairs.length ? replaceTokens(normalized, pairs) : null;
const translatedParse =
translated && translated !== stripped
? parseDateFromText(translated, referenceDate)
: null;
// Prefer direct English parse; fall back to translated parse
const useTranslated = !directParse && !!translatedParse;
const englishInput = useTranslated ? translated : stripped;
const seen = new Set();
const results = [];
const exact = directParse || translatedParse;
if (exact) {
seen.add(exact.unix);
const exactLabel =
useTranslated && pairs.length
? reverseTokens(englishInput, pairs)
: englishInput;
results.push({ label: exactLabel, query: englishInput, ...exact });
}
buildSuggestionCandidates(englishInput).some(candidate => {
if (results.length >= MAX_SUGGESTIONS) return true;
const result = parseDateFromText(candidate, referenceDate);
if (result && !seen.has(result.unix)) {
seen.add(result.unix);
const label =
useTranslated && pairs.length
? reverseTokens(candidate, pairs)
: candidate;
results.push({ label, query: candidate, ...result });
}
return false;
});
return results;
};
@@ -0,0 +1,806 @@
/**
* Parses natural language text into a future date.
*
* Flow: clean the input → try each matcher in order → return the first future date.
* The MATCHERS order matters — see the comment above the array.
*/
import {
add,
startOfDay,
getDay,
isSaturday,
isSunday,
nextFriday,
nextSaturday,
getUnixTime,
isValid,
startOfWeek,
addWeeks,
isAfter,
isBefore,
endOfMonth,
} from 'date-fns';
import {
WEEKDAY_MAP,
MONTH_MAP,
RELATIVE_DAY_MAP,
UNIT_MAP,
WORD_NUMBER_MAP,
NEXT_WEEKDAY_FN,
TIME_OF_DAY_MAP,
TOD_HOUR_RANGE,
HALF_UNIT_DURATIONS,
sanitize,
stripNoise,
parseNumber,
parseTimeString,
applyTimeToDate,
applyTimeOrDefault,
strictDate,
futureOrNextYear,
ensureFutureOrNextDay,
inferHoursFromTOD,
addFractionalSafe,
} from './tokenMaps';
// ─── Regex Fragments (derived from maps) ────────────────────────────────────
const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|');
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const UNIT_NAMES = Object.keys(UNIT_MAP).join('|');
const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|');
const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|');
const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight';
const NUM_RE = `(\\d+(?:\\.5)?|${WORD_NUMBERS})`;
const UNIT_RE = `(${UNIT_NAMES})`;
const TIME_SUFFIX_RE =
'(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?';
const ORDINAL_MAP = {
first: 1,
second: 2,
third: 3,
fourth: 4,
fifth: 5,
sixth: 6,
seventh: 7,
eighth: 8,
ninth: 9,
tenth: 10,
};
const parseOrdinal = str => {
if (ORDINAL_MAP[str]) return ORDINAL_MAP[str];
return parseInt(str.replace(/(?:st|nd|rd|th)$/, ''), 10) || null;
};
const ORDINAL_WORDS = Object.keys(ORDINAL_MAP).join('|');
const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
// ─── Pre-compiled Regexes ───────────────────────────────────────────────────
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
const DURATION_FROM_NOW_RE = new RegExp(
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
);
const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
const RELATIVE_DAY_TOD_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
);
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
const RELATIVE_DAY_AT_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?` +
'(\\d{1,2}(?::\\d{2})?\\s*' +
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$'
);
const RELATIVE_DAY_SAME_TIME_RE = new RegExp(
`^(?:(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)|(?:same\\s+time|this\\s+time)\\s+(${RELATIVE_DAYS}))$`
);
const NEXT_UNIT_RE = new RegExp(
`^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$`
);
const NEXT_MONTH_RE = new RegExp(`^next\\s+(${MONTH_NAMES})${TIME_SUFFIX_RE}$`);
const NEXT_WEEKDAY_TOD_RE = new RegExp(
`^next\\s+(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
);
const NEXT_WEEKDAY_RE = new RegExp(
`^(?:(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week` +
`|next\\s+week\\s+(${WEEKDAY_NAMES})` +
`|next\\s+(${WEEKDAY_NAMES}))${TIME_SUFFIX_RE}$`
);
const SAME_TIME_WEEKDAY_RE = new RegExp(
`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`
);
const WEEKDAY_TOD_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?` +
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
);
const WEEKDAY_TOD_TIME_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?` +
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
const WEEKDAY_TIME_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`
);
const TIME_ONLY_MERIDIEM_RE =
/^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/;
const TIME_ONLY_24H_RE = /^(?:at\s+)?(\d{1,2}:\d{2})$/;
const TOD_WITH_TIME_RE = new RegExp(
`^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+` +
'(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*' +
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
);
const TOD_PLAIN_RE = new RegExp(
'(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?' +
`(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$`
);
const ABSOLUTE_DATE_RE = new RegExp(
`^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?` +
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
);
const ABSOLUTE_DATE_REVERSED_RE = new RegExp(
`^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})` +
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
);
const MONTH_YEAR_RE = new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`);
// "april first week", "first week of april", "march 2nd day", "5th day of jan"
const MONTH_ORDINAL_RE = new RegExp(
`^(?:(${MONTH_NAMES})\\s+${ORDINAL_RE}\\s+(week|day)|${ORDINAL_RE}\\s+(week|day)\\s+of\\s+(${MONTH_NAMES}))${TIME_SUFFIX_RE}$`
);
const DAY_AFTER_TOMORROW_RE = new RegExp(
`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`
);
const COMPOUND_DURATION_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+(?:and\\s+)?${NUM_RE}\\s+${UNIT_RE}$`
);
const DURATION_AT_TIME_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+at\\s+` +
'(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
);
const END_OF_RE = /^end\s+of\s+(?:the\s+)?(week|month|day)$/;
const END_OF_NEXT_RE = /^end\s+of\s+(?:the\s+)?next\s+(week|month)$/;
const START_OF_NEXT_RE =
/^(?:beginning|start)\s+of\s+(?:the\s+)?next\s+(week|month)$/;
const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/;
const EARLY_LATE_TOD_RE = new RegExp(
`^(early|late)\\s+(${TIME_OF_DAY_NAMES})$`
);
const ONE_AND_HALF_RE = new RegExp(
`^(?:in\\s+)?(?:one\\s+and\\s+(?:a\\s+)?half|an?\\s+hour\\s+and\\s+(?:a\\s+)?half)(?:\\s+${UNIT_RE})?$`
);
const NEXT_BUSINESS_DAY_RE = /^next\s+(?:business|working)\s+day$/;
const TIME_SUFFIX_COMPILED = new RegExp(`${TIME_SUFFIX_RE}$`);
const ISO_DATE_RE = new RegExp(
`^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX_COMPILED.source}`
);
const SLASH_DATE_RE = new RegExp(
`^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const DASH_DATE_RE = new RegExp(
`^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const DOT_DATE_RE = new RegExp(
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
// ─── Pattern Matchers ───────────────────────────────────────────────────────
/** Read amount and unit from a regex match, then add to now. */
const parseDuration = (match, now) => {
if (!match) return null;
const amount = parseNumber(match[1]);
const unit = UNIT_MAP[match[2]];
if (amount == null || !unit) return null;
return addFractionalSafe(now, unit, amount);
};
/** Handle "in 2 hours", "half day", "3h30m", "5 min from now". */
const matchDuration = (text, now) => {
const half = text.match(HALF_UNIT_RE);
if (half) {
return HALF_UNIT_DURATIONS[half[1]]
? add(now, HALF_UNIT_DURATIONS[half[1]])
: null;
}
// "one and a half hours", "an hour and a half"
const oneHalf = text.match(ONE_AND_HALF_RE);
if (oneHalf) {
const unit = UNIT_MAP[oneHalf[1]] || 'hours';
return addFractionalSafe(now, unit, 1.5);
}
const compound = text.match(COMPOUND_DURATION_RE);
if (compound) {
const a1 = parseNumber(compound[1]);
const u1 = UNIT_MAP[compound[2]];
const a2 = parseNumber(compound[3]);
const u2 = UNIT_MAP[compound[4]];
if (a1 == null || !u1 || a2 == null || !u2) {
return null;
}
return add(add(now, { [u1]: a1 }), { [u2]: a2 });
}
const atTime = text.match(DURATION_AT_TIME_RE);
if (atTime) {
const amount = parseNumber(atTime[1]);
const unit = UNIT_MAP[atTime[2]];
const time = parseTimeString(atTime[3]);
if (amount == null || !unit || !time) {
return null;
}
return applyTimeToDate(
add(now, { [unit]: amount }),
time.hours,
time.minutes
);
}
return (
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
);
};
/** Set time on a day offset. If the result is already past, move to the next day. */
const applyTimeWithRollover = (offset, hours, minutes, now) => {
const base = add(startOfDay(now), { days: offset });
const date = applyTimeToDate(base, hours, minutes);
if (isAfter(date, now)) return date;
return applyTimeToDate(add(base, { days: 1 }), hours, minutes);
};
/** Handle "today", "tonight", "tomorrow" with optional time. */
const matchRelativeDay = (text, now) => {
const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE);
if (dayOnlyMatch) {
const key = dayOnlyMatch[1];
const offset = RELATIVE_DAY_MAP[key];
if (key === 'tonight' || key === 'tonite') {
return ensureFutureOrNextDay(
applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0),
now
);
}
if (offset === 1) {
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0);
}
return add(now, { hours: 1 });
}
const dayTodTimeMatch = text.match(RELATIVE_DAY_TOD_TIME_RE);
if (dayTodTimeMatch) {
const timeParts = dayTodTimeMatch[3].split(':');
const time = inferHoursFromTOD(
dayTodTimeMatch[2],
timeParts[0],
timeParts[1]
);
if (!time) return null;
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayTodTimeMatch[1]],
time.hours,
time.minutes,
now
);
}
const dayTodMatch = text.match(RELATIVE_DAY_TOD_RE);
if (dayTodMatch) {
const { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]];
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayTodMatch[1]],
hours,
minutes,
now
);
}
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
if (dayAtTimeMatch) {
const [, dayKey, timeRaw] = dayAtTimeMatch;
const bare = /^(tonight|tonite)$/.test(dayKey) && !/[ap]m/i.test(timeRaw);
const time = bare
? inferHoursFromTOD('tonight', ...timeRaw.split(':'))
: parseTimeString(timeRaw);
if (!time) return null;
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayKey],
time.hours,
time.minutes,
now
);
}
const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE);
if (sameTimeMatch) {
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1] || sameTimeMatch[2]];
if (offset <= 0) return null;
return applyTimeToDate(
add(startOfDay(now), { days: offset }),
now.getHours(),
now.getMinutes()
);
}
return null;
};
/** Find the given weekday in next week (not this week). */
const nextWeekdayInNextWeek = (dayIndex, now) => {
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const date = fn(now);
const sameWeek =
startOfWeek(now, { weekStartsOn: 1 }).getTime() ===
startOfWeek(date, { weekStartsOn: 1 }).getTime();
return sameWeek ? fn(date) : date;
};
/** Handle "next friday", "next week", "next month", "next january", etc. */
const matchNextPattern = (text, now) => {
const nextUnitMatch = text.match(NEXT_UNIT_RE);
if (nextUnitMatch) {
const unit = nextUnitMatch[1];
if (unit === 'hour') return add(now, { hours: 1 });
if (unit === 'minute') return add(now, { minutes: 1 });
if (unit === 'week') {
const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
return applyTimeOrDefault(base, nextUnitMatch[2]);
}
const base = add(startOfDay(now), { [`${unit}s`]: 1 });
return applyTimeOrDefault(base, nextUnitMatch[2]);
}
const nextMonthMatch = text.match(NEXT_MONTH_RE);
if (nextMonthMatch) {
const monthIdx = MONTH_MAP[nextMonthMatch[1]];
let year = now.getFullYear();
if (monthIdx <= now.getMonth()) year += 1;
const base = new Date(year, monthIdx, 1);
return applyTimeOrDefault(base, nextMonthMatch[2]);
}
// "next monday morning", "next friday midnight" — weekday + time-of-day
const nextTodMatch = text.match(NEXT_WEEKDAY_TOD_RE);
if (nextTodMatch) {
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[nextTodMatch[1]], now);
if (!date) return null;
const { hours, minutes } = TIME_OF_DAY_MAP[nextTodMatch[2]];
return applyTimeToDate(date, hours, minutes);
}
// "monday of next week", "next week monday", "next friday" — all with optional time
const weekdayMatch = text.match(NEXT_WEEKDAY_RE);
if (weekdayMatch) {
const dayName = weekdayMatch[1] || weekdayMatch[2] || weekdayMatch[3];
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[dayName], now);
if (!date) return null;
return applyTimeOrDefault(date, weekdayMatch[4]);
}
return null;
};
/** Find the next occurrence of a weekday, with optional time. */
const resolveWeekdayDate = (dayIndex, timeStr, now) => {
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
let adjusted = timeStr;
if (timeStr && /^\d{1,2}$/.test(timeStr.trim())) {
const h = parseInt(timeStr, 10);
if (h >= 1 && h <= 7) adjusted = `${h}pm`;
}
if (getDay(now) === dayIndex) {
const todayDate = applyTimeOrDefault(now, adjusted);
if (todayDate && isAfter(todayDate, now)) return todayDate;
}
return applyTimeOrDefault(fn(now), adjusted);
};
/** Handle "friday", "monday 3pm", "wed morning", "same time friday". */
const matchWeekday = (text, now) => {
const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE);
if (sameTimeWeekday) {
const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const target = fn(now);
return applyTimeToDate(target, now.getHours(), now.getMinutes());
}
// "monday morning 6", "friday evening 7" — weekday + tod + bare number
const todTimeMatch = text.match(WEEKDAY_TOD_TIME_RE);
if (todTimeMatch) {
const dayIndex = WEEKDAY_MAP[todTimeMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const timeParts = todTimeMatch[3].split(':');
const time = inferHoursFromTOD(todTimeMatch[2], timeParts[0], timeParts[1]);
if (!time) return null;
const target =
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
const date = applyTimeToDate(target, time.hours, time.minutes);
return isAfter(date, now)
? date
: applyTimeToDate(fn(now), time.hours, time.minutes);
}
// "monday morning", "friday midnight", "wednesday evening", etc.
const todMatch = text.match(WEEKDAY_TOD_RE);
if (todMatch) {
const dayIndex = WEEKDAY_MAP[todMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const { hours, minutes } = TIME_OF_DAY_MAP[todMatch[2]];
const target =
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
const date = applyTimeToDate(target, hours, minutes);
return isAfter(date, now) ? date : applyTimeToDate(fn(now), hours, minutes);
}
const match = text.match(WEEKDAY_TIME_RE);
if (!match) return null;
return resolveWeekdayDate(WEEKDAY_MAP[match[1]], match[2], now);
};
/** Handle a standalone time like "3pm", "14:30", "at 9am". */
const matchTimeOnly = (text, now) => {
const match =
text.match(TIME_ONLY_MERIDIEM_RE) || text.match(TIME_ONLY_24H_RE);
if (!match) return null;
const time = parseTimeString(match[1]);
if (!time) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, time.hours, time.minutes),
now
);
};
/** Handle "morning", "evening 6pm", "eod", "this afternoon". */
const matchTimeOfDay = (text, now) => {
const todWithTime = text.match(TOD_WITH_TIME_RE);
if (todWithTime) {
const rawTime = todWithTime[2].trim();
const hasMeridiem = /(?:am|pm|a\.m|p\.m)/i.test(rawTime);
let time;
if (hasMeridiem) {
time = parseTimeString(rawTime);
const range = TOD_HOUR_RANGE[todWithTime[1]];
if (!time) return null;
if (range) {
const h = time.hours === 0 ? 24 : time.hours;
if (h < range[0] || h >= range[1]) return null;
}
} else {
const parts = rawTime.split(':');
time = inferHoursFromTOD(todWithTime[1], parts[0], parts[1]);
}
if (!time) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, time.hours, time.minutes),
now
);
}
// "early morning" → 7am, "late evening" → 21:00, "late night" → 23:00
const earlyLate = text.match(EARLY_LATE_TOD_RE);
if (earlyLate) {
const tod = TIME_OF_DAY_MAP[earlyLate[2]];
if (!tod) return null;
const shift = earlyLate[1] === 'early' ? -1 : 2;
return ensureFutureOrNextDay(
applyTimeToDate(now, tod.hours + shift, 0),
now
);
}
const match = text.match(TOD_PLAIN_RE);
if (!match) return null;
const key = text
.replace(/^(?:later|in)\s+/, '')
.replace(/^(?:this|the)\s+/, '')
.trim();
const tod = TIME_OF_DAY_MAP[key];
if (!tod) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, tod.hours, tod.minutes),
now
);
};
/** Turn month + day + optional year into a future date. */
const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => {
let year = now.getFullYear();
if (yearStr && /next\s+year/i.test(yearStr)) {
year += 1;
} else if (yearStr) {
year = parseInt(yearStr, 10);
}
if (yearStr) {
const base = strictDate(year, month, day);
if (!base) return null;
const date = applyTimeOrDefault(base, timeStr);
return date && isAfter(date, now) ? date : null;
}
return futureOrNextYear(year, month, day, timeStr, now);
};
/** Handle "jan 15", "15 march", "december 2025". */
const matchNamedDate = (text, now) => {
const abs = text.match(ABSOLUTE_DATE_RE);
if (abs) {
return resolveAbsoluteDate(
MONTH_MAP[abs[1]],
parseInt(abs[2], 10),
abs[3],
abs[4],
now
);
}
const rev = text.match(ABSOLUTE_DATE_REVERSED_RE);
if (rev) {
return resolveAbsoluteDate(
MONTH_MAP[rev[2]],
parseInt(rev[1], 10),
rev[3],
rev[4],
now
);
}
const my = text.match(MONTH_YEAR_RE);
if (my) {
const date = new Date(parseInt(my[2], 10), MONTH_MAP[my[1]], 1);
if (!isValid(date)) return null;
const result = applyTimeToDate(date, 9, 0);
return isAfter(result, now) ? result : null;
}
// "april first week", "first week of april", "march 2nd day", etc.
const mo = text.match(MONTH_ORDINAL_RE);
if (mo) {
// Groups: (1)month-A (2)ordinal-A (3)unit-A | (4)ordinal-B (5)unit-B (6)month-B (7)time
const monthIdx = MONTH_MAP[mo[1] || mo[6]];
const num = parseOrdinal(mo[2] || mo[4]);
const unit = mo[3] || mo[5];
const timeStr = mo[7];
if (!num || num < 1) return null;
if (unit === 'day') {
if (num > 31) return null;
return resolveAbsoluteDate(monthIdx, num, null, timeStr, now);
}
// unit === 'week'
if (num > 5) return null;
const weekStartDay = (num - 1) * 7 + 1;
let year = now.getFullYear();
if (
monthIdx < now.getMonth() ||
(monthIdx === now.getMonth() && now.getDate() > weekStartDay)
) {
year += 1;
}
// Reject if weekStartDay overflows the month (e.g. feb fifth week = day 29 in non-leap)
const daysInMonth = new Date(year, monthIdx + 1, 0).getDate();
if (weekStartDay > daysInMonth) return null;
const d = new Date(year, monthIdx, weekStartDay);
if (!isValid(d)) return null;
const result = applyTimeOrDefault(d, timeStr);
return result && isAfter(result, now) ? result : null;
}
return null;
};
/** Build a date from year/month/day numbers, with optional time. */
const buildDateWithOptionalTime = (year, month, day, timeStr) => {
const date = strictDate(year, month, day);
if (!date) return null;
return applyTimeOrDefault(date, timeStr);
};
// When both values are ≤ 12 (ambiguous), dayFirst controls the fallback:
// dayFirst=false (slash M/D/Y) → month first
// dayFirst=true (dash/dot D-M-Y, D.M.Y) → day first
const disambiguateDayMonth = (a, b, dayFirst = false) => {
if (a > 12) return { day: a, month: b - 1 };
if (b > 12) return { month: a - 1, day: b };
return dayFirst ? { day: a, month: b - 1 } : { month: a - 1, day: b };
};
/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */
const matchFormalDate = (text, now) => {
const ensureFuture = date => (date && isAfter(date, now) ? date : null);
const isoMatch = text.match(ISO_DATE_RE);
if (isoMatch) {
return ensureFuture(
buildDateWithOptionalTime(
parseInt(isoMatch[1], 10),
parseInt(isoMatch[2], 10) - 1,
parseInt(isoMatch[3], 10),
isoMatch[4]
)
);
}
// Slash = M/D/Y (US), Dash/Dot = D-M-Y / D.M.Y (European)
const formats = [
{ re: SLASH_DATE_RE, dayFirst: false },
{ re: DASH_DATE_RE, dayFirst: true },
{ re: DOT_DATE_RE, dayFirst: true },
];
let result = null;
formats.some(({ re, dayFirst }) => {
const m = text.match(re);
if (!m) return false;
const { month, day } = disambiguateDayMonth(
parseInt(m[1], 10),
parseInt(m[2], 10),
dayFirst
);
result = ensureFuture(
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
);
return true;
});
return result;
};
/** Handle "day after tomorrow", "end of week", "this weekend", "later today". */
const matchSpecial = (text, now) => {
const dat = text.match(DAY_AFTER_TOMORROW_RE);
if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]);
const eof = text.match(END_OF_RE);
if (eof) {
if (eof[1] === 'day') return applyTimeToDate(now, 17, 0);
if (eof[1] === 'week') {
const fri = applyTimeToDate(now, 17, 0);
if (getDay(now) === 5 && isAfter(fri, now)) return fri;
return applyTimeToDate(nextFriday(now), 17, 0);
}
if (eof[1] === 'month') {
const eom = applyTimeToDate(endOfMonth(now), 17, 0);
if (isAfter(eom, now)) return eom;
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
}
}
// "end of next week", "end of next month"
const eofNext = text.match(END_OF_NEXT_RE);
if (eofNext) {
if (eofNext[1] === 'week') {
const nextWeekStart = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
return applyTimeToDate(add(nextWeekStart, { days: 4 }), 17, 0);
}
if (eofNext[1] === 'month') {
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
}
}
// "beginning of next week", "start of next month"
const sofNext = text.match(START_OF_NEXT_RE);
if (sofNext) {
if (sofNext[1] === 'week') {
return applyTimeToDate(
startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 }),
9,
0
);
}
if (sofNext[1] === 'month') {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return applyTimeToDate(nextMonth, 9, 0);
}
}
// "next business day", "next working day"
if (NEXT_BUSINESS_DAY_RE.test(text)) {
let d = add(startOfDay(now), { days: 1 });
while (isSaturday(d) || isSunday(d)) d = add(d, { days: 1 });
return applyTimeToDate(d, 9, 0);
}
if (LATER_TODAY_RE.test(text)) return add(now, { hours: 3 });
const weekendMatch = text.match(
/^(this weekend|weekend|next weekend)(?:\s+(?:at\s+)?(.+))?$/
);
if (weekendMatch) {
const isNext = weekendMatch[1] === 'next weekend';
const timeStr = weekendMatch[2];
if (isNext) {
const sat = nextSaturday(now);
const d = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 });
return applyTimeOrDefault(d, timeStr);
}
if (isSaturday(now)) {
if (!timeStr) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
if (now.getHours() < 18) return add(now, { hours: 2 });
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0);
}
const today = applyTimeOrDefault(now, timeStr);
if (today && isAfter(today, now)) return today;
return applyTimeOrDefault(add(startOfDay(now), { days: 1 }), timeStr);
}
if (isSunday(now)) {
if (!timeStr) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
return add(now, { hours: 2 });
}
const today = applyTimeOrDefault(now, timeStr);
if (today && isAfter(today, now)) return today;
}
return applyTimeOrDefault(nextSaturday(now), timeStr);
}
return null;
};
// ─── Main Parser ────────────────────────────────────────────────────────────
// Order matters — first match wins. Common patterns go first.
// Do not reorder without running the spec.
const MATCHERS = [
matchDuration, // "in 2 hours", "half day", "3h30m"
matchSpecial, // "end of week", "later today", "this weekend"
matchRelativeDay, // "tomorrow 3pm", "tonight", "today morning"
matchNextPattern, // "next friday", "next week", "next month"
matchTimeOfDay, // "morning", "evening 6pm", "eod"
matchWeekday, // "friday", "monday 3pm", "wed morning"
matchTimeOnly, // "3pm", "14:30" (must be after weekday to avoid conflicts)
matchNamedDate, // "jan 15", "march 20 next year"
matchFormalDate, // "2025-01-15", "1/15/2025" (least common, last)
];
/**
* Parse free-form text into a future date.
* Returns { date, unix } or null. Only returns dates after referenceDate.
*
* @param {string} text - user input like "in 2 hours" or "next friday 3pm"
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
* @returns {{ date: Date, unix: number } | null}
*/
export const parseDateFromText = (text, referenceDate = new Date()) => {
if (!text || typeof text !== 'string') return null;
const normalized = stripNoise(sanitize(text));
if (!normalized) return null;
const maxDate = add(referenceDate, { years: 999 });
const isValidFuture = d =>
d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d);
let result = null;
MATCHERS.some(matcher => {
const d = matcher(normalized, referenceDate);
if (isValidFuture(d)) {
result = { date: d, unix: getUnixTime(d) };
return true;
}
return false;
});
return result;
};
@@ -0,0 +1,101 @@
/**
* Builds autocomplete suggestions as the user types a snooze date.
* Matches partial input against known phrases and ranks them by closeness.
*/
import {
UNIT_MAP,
WEEKDAY_MAP,
TIME_OF_DAY_MAP,
RELATIVE_DAY_MAP,
WORD_NUMBER_MAP,
MONTH_MAP,
HALF_UNIT_DURATIONS,
} from './tokenMaps';
const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter(
u => u !== 'seconds'
);
const FULL_WEEKDAYS = Object.keys(WEEKDAY_MAP).filter(k => k.length > 3);
const TOD_NAMES = Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' '));
const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3);
const ALL_SUGGESTION_PHRASES = [
...Object.keys(RELATIVE_DAY_MAP),
...FULL_WEEKDAYS,
...TOD_NAMES,
'next week',
'next month',
'this weekend',
'next weekend',
'day after tomorrow',
'later today',
'end of day',
'end of week',
'end of month',
...['morning', 'afternoon', 'evening'].map(tod => `tomorrow ${tod}`),
...FULL_WEEKDAYS.map(wd => `next ${wd}`),
...FULL_WEEKDAYS.map(wd => `this ${wd}`),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `next ${wd} ${tod}`)),
...MONTH_NAMES_LONG.map(m => `${m} 1`),
];
/** Check how closely the input matches a candidate. -1 = no match, 0 = exact prefix, N = extra words needed. */
const prefixMatchScore = (candidate, input) => {
if (candidate === input) return -1;
if (candidate.startsWith(input)) return 0;
const inputWords = input.split(' ');
const candidateWords = candidate.split(' ');
const lastIdx = inputWords.reduce((prev, iw) => {
if (prev === -2) return -2;
const idx = candidateWords.findIndex(
(cw, ci) => ci > prev && cw.startsWith(iw)
);
return idx === -1 ? -2 : idx;
}, -1);
if (lastIdx === -2) return -1;
return candidateWords.length - inputWords.length;
};
export const MAX_SUGGESTIONS = 5;
/** Turn user input into a ranked list of suggestion strings to try parsing. */
export const buildSuggestionCandidates = text => {
if (!text) return [];
if (/^\d/.test(text)) {
const num = text.match(/^\d+(?:\.5)?/)[0];
const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`);
const trimmed = text.replace(/\s+/g, ' ').trim();
const spaced = trimmed.replace(/(\d)([a-z])/i, '$1 $2');
return spaced.length > num.length
? candidates.filter(c => c.startsWith(spaced))
: candidates;
}
if (text.length >= 2 && 'half'.startsWith(text)) {
return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`);
}
const wordNum = WORD_NUMBER_MAP[text];
if (wordNum != null && wordNum >= 1) {
return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`);
}
const scored = ALL_SUGGESTION_PHRASES.reduce((acc, candidate) => {
const score = prefixMatchScore(candidate, text);
if (score >= 0) acc.push({ candidate, score });
return acc;
}, []);
scored.sort((a, b) => a.score - b.score);
const seen = new Set();
return scored.reduce((acc, { candidate }) => {
if (acc.length < MAX_SUGGESTIONS * 3 && !seen.has(candidate)) {
seen.add(candidate);
acc.push(candidate);
}
return acc;
}, []);
};
@@ -0,0 +1,395 @@
/**
* Shared lookup tables and helper functions used by the parser,
* suggestions, and localization modules.
*/
import {
add,
set,
isValid,
isAfter,
nextMonday,
nextTuesday,
nextWednesday,
nextThursday,
nextFriday,
nextSaturday,
nextSunday,
} from 'date-fns';
// ─── Token Maps ──────────────────────────────────────────────────────────────
// All keys are lowercase. Short forms and full names both work.
/** Weekday name or short form → day index (0 = Sunday). */
export const WEEKDAY_MAP = {
sunday: 0,
sun: 0,
monday: 1,
mon: 1,
tuesday: 2,
tue: 2,
tues: 2,
wednesday: 3,
wed: 3,
thursday: 4,
thu: 4,
thur: 4,
thurs: 4,
friday: 5,
fri: 5,
saturday: 6,
sat: 6,
};
/** Month name or short form → month index (0 = January). */
export const MONTH_MAP = {
january: 0,
jan: 0,
february: 1,
feb: 1,
march: 2,
mar: 2,
april: 3,
apr: 3,
may: 4,
june: 5,
jun: 5,
july: 6,
jul: 6,
august: 7,
aug: 7,
september: 8,
sep: 8,
sept: 8,
october: 9,
oct: 9,
november: 10,
nov: 10,
december: 11,
dec: 11,
};
/** Words like "today" or "tomorrow" → how many days from now. */
export const RELATIVE_DAY_MAP = {
today: 0,
tonight: 0,
tonite: 0,
tomorrow: 1,
tmr: 1,
tmrw: 1,
};
/** Unit shorthand → full unit name used by date-fns. */
export const UNIT_MAP = {
m: 'minutes',
min: 'minutes',
mins: 'minutes',
minute: 'minutes',
minutes: 'minutes',
h: 'hours',
hr: 'hours',
hrs: 'hours',
hour: 'hours',
hours: 'hours',
d: 'days',
day: 'days',
days: 'days',
w: 'weeks',
wk: 'weeks',
wks: 'weeks',
week: 'weeks',
weeks: 'weeks',
mo: 'months',
month: 'months',
months: 'months',
y: 'years',
yr: 'years',
yrs: 'years',
year: 'years',
years: 'years',
};
/** English number words → their numeric value. */
export const WORD_NUMBER_MAP = {
a: 1,
an: 1,
one: 1,
couple: 2,
few: 3,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
eleven: 11,
twelve: 12,
thirteen: 13,
fourteen: 14,
fifteen: 15,
sixteen: 16,
seventeen: 17,
eighteen: 18,
nineteen: 19,
twenty: 20,
thirty: 30,
forty: 40,
fifty: 50,
sixty: 60,
ninety: 90,
half: 0.5,
};
/** Day index → the date-fns function that finds the next occurrence. */
export const NEXT_WEEKDAY_FN = {
0: nextSunday,
1: nextMonday,
2: nextTuesday,
3: nextWednesday,
4: nextThursday,
5: nextFriday,
6: nextSaturday,
};
/** Time-of-day label → default hour and minute. */
export const TIME_OF_DAY_MAP = {
morning: { hours: 9, minutes: 0 },
noon: { hours: 12, minutes: 0 },
afternoon: { hours: 14, minutes: 0 },
evening: { hours: 18, minutes: 0 },
night: { hours: 20, minutes: 0 },
tonight: { hours: 20, minutes: 0 },
midnight: { hours: 0, minutes: 0 },
eod: { hours: 17, minutes: 0 },
'end of day': { hours: 17, minutes: 0 },
'end of the day': { hours: 17, minutes: 0 },
};
/** Allowed hour range per label — used to pick am or pm when not specified. */
export const TOD_HOUR_RANGE = {
morning: [4, 12],
noon: [11, 13],
afternoon: [12, 18],
evening: [16, 22],
night: [18, 24],
tonight: [18, 24],
midnight: [23, 25],
};
/** What "half hour", "half day", etc. actually mean in date-fns terms. */
export const HALF_UNIT_DURATIONS = {
hour: { minutes: 30 },
day: { hours: 12 },
week: { days: 3, hours: 12 },
month: { days: 15 },
year: { months: 6 },
};
const FRACTIONAL_CONVERT = {
hours: { unit: 'minutes', factor: 60 },
days: { unit: 'hours', factor: 24 },
weeks: { unit: 'days', factor: 7 },
months: { unit: 'days', factor: 30 },
years: { unit: 'months', factor: 12 },
};
// ─── Unicode / Normalization ────────────────────────────────────────────────
// Turn non-ASCII digits and punctuation into plain ASCII so the
// parser only has to deal with standard characters.
const UNICODE_DIGIT_RANGES = [
[0x30, 0x39],
[0x660, 0x669], // Arabic-Indic
[0x6f0, 0x6f9], // Eastern Arabic-Indic
[0x966, 0x96f], // Devanagari
[0x9e6, 0x9ef], // Bengali
[0xa66, 0xa6f], // Gurmukhi
[0xae6, 0xaef], // Gujarati
[0xb66, 0xb6f], // Oriya
[0xbe6, 0xbef], // Tamil
[0xc66, 0xc6f], // Telugu
[0xce6, 0xcef], // Kannada
[0xd66, 0xd6f], // Malayalam
];
const toAsciiDigit = char => {
const code = char.codePointAt(0);
const range = UNICODE_DIGIT_RANGES.find(
([start, end]) => code >= start && code <= end
);
if (!range) return char;
return String(code - range[0]);
};
/** Turn non-ASCII digits (Arabic, Devanagari, etc.) into 0-9. */
export const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit);
const ARABIC_PUNCT_MAP = {
'\u061f': '?',
'\u060c': ',',
'\u061b': ';',
'\u066b': '.',
};
const NOISE_RE =
/^(?:(?:can|could|will|would)\s+you\s+)?(?:(?:please|pls|plz|kindly)\s+)?(?:(?:snooze|remind(?:\s+me)?|set(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze|timer))?|add(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze))?|schedule|postpone|defer|delay|push)(?:\s+(?:it|this))?\s+)?(?:(?:on|to|for|at|until|till|by|from|after|within)\s+)?/;
const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/;
/** Clean up raw input: lowercase, remove punctuation, collapse spaces. */
export const sanitize = text =>
normalizeDigits(
text
.normalize('NFKC')
.toLowerCase()
.replace(/[\u200f\u200e\u066c\u0640]/g, '')
.replace(/[\u064b-\u065f]/g, '')
.replace(/\u00a0/g, ' ')
.replace(/[\u061f\u060c\u061b\u066b]/g, c => ARABIC_PUNCT_MAP[c])
)
.replace(/[,!?;]+/g, ' ')
.replace(/\.+$/g, '')
.replace(/\s+/g, ' ')
.trim();
/** Strip filler words like "please snooze for" and fix typos like "tommorow". */
export const stripNoise = text => {
let r = text
.replace(/\ba\s+fortnight\b/g, '2 weeks')
.replace(/\bfortnight\b/g, '2 weeks')
.replace(NOISE_RE, '')
.replace(APPROX_RE, '')
.replace(/^the\s+/, '')
.replace(/\bnxt\b/g, 'next')
.replace(/\ba\s+couple\s+of\b/g, 'couple')
.replace(/\bcouple\s+of\b/g, 'couple')
.replace(/\ba\s+couple\b/g, 'couple')
.replace(/\ba\s+few\b/g, 'few')
.replace(
/\b(\d+)\s*(?:h|hr|hours?)[\s]*(\d+)\s*(?:m|min|minutes?)\b/g,
(_, h, m) =>
`${h} ${h === '1' ? 'hour' : 'hours'} ${m} ${m === '1' ? 'minute' : 'minutes'}`
)
.replace(/\b(\d+)h\b/g, (_, h) => `${h} ${h === '1' ? 'hour' : 'hours'}`)
.replace(
/\b(\d+)m\b/g,
(_, m) => `${m} ${m === '1' ? 'minute' : 'minutes'}`
)
.replace(/\btomm?orow\b/g, 'tomorrow')
.replace(/\s+later$/, '')
.trim();
// bare unit without number: "month later" → "1 month", "week" stays
r = r.replace(/^(minutes?|hours?|days?|weeks?|months?|years?)$/, '1 $1');
return r;
};
// ─── Utility Functions ──────────────────────────────────────────────────────
/** Turn a string into a number. Works with digits ("5") and words ("five"). */
export const parseNumber = str => {
if (!str) return null;
const lower = normalizeDigits(str.toLowerCase().trim());
if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower];
const num = Number(lower);
return Number.isNaN(num) ? null : num;
};
/** Set the time on a date, clearing seconds and milliseconds. */
export const applyTimeToDate = (date, hours, minutes = 0) =>
set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
/** Parse "3pm", "14:30", or "2:00am" into { hours, minutes }. Returns null if invalid. */
export const parseTimeString = timeStr => {
if (!timeStr) return null;
const match = timeStr
.toLowerCase()
.replace(/\s+/g, '')
.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/);
if (!match) return null;
const raw = parseInt(match[1], 10);
const minutes = match[2] ? parseInt(match[2], 10) : 0;
const meridiem = match[3]?.replace(/\./g, '');
if (meridiem && (raw < 1 || raw > 12)) return null;
const toHours = (h, m) => {
if (m === 'pm' && h < 12) return h + 12;
if (m === 'am' && h === 12) return 0;
return h;
};
const hours = toHours(raw, meridiem);
if (hours > 23 || minutes > 59) return null;
return { hours, minutes };
};
/** Apply a time string to a date. Falls back to 9 AM if no time is given. */
export const applyTimeOrDefault = (date, timeStr, defaultHours = 9) => {
if (timeStr) {
const time = parseTimeString(timeStr);
if (!time) return null;
return applyTimeToDate(date, time.hours, time.minutes);
}
return applyTimeToDate(date, defaultHours, 0);
};
/** Build a Date only if the day actually exists (e.g. rejects Feb 30). */
export const strictDate = (year, month, day) => {
const date = new Date(year, month, day);
if (
!isValid(date) ||
date.getFullYear() !== year ||
date.getMonth() !== month ||
date.getDate() !== day
)
return null;
return date;
};
/** Try up to 8 years ahead to find a valid future date (handles Feb 29 leap years). */
export const futureOrNextYear = (year, month, day, timeStr, now) => {
for (let i = 0; i < 9; i += 1) {
const base = strictDate(year + i, month, day);
if (base) {
const date = applyTimeOrDefault(base, timeStr);
if (!date) return null;
if (isAfter(date, now)) return date;
}
}
return null;
};
/** If the date is already past, push it to the next day. */
export const ensureFutureOrNextDay = (date, now) =>
isAfter(date, now) ? date : add(date, { days: 1 });
/** Figure out am/pm from context: "morning 6" → 6am, "evening 6" → 6pm. */
export const inferHoursFromTOD = (todLabel, rawHour, rawMinutes) => {
const h = parseInt(rawHour, 10);
const m = rawMinutes ? parseInt(rawMinutes, 10) : 0;
if (Number.isNaN(h) || h < 1 || h > 12 || m > 59) return null;
const range = TOD_HOUR_RANGE[todLabel];
if (!range) return { hours: h, minutes: m };
// Try both am and pm interpretations, pick the one in range
const am = h === 12 ? 0 : h;
const pm = h === 12 ? 12 : h + 12;
const inRange = v => v >= range[0] && v < range[1];
if (inRange(am)) return { hours: am, minutes: m };
if (inRange(pm)) return { hours: pm, minutes: m };
const mid = (range[0] + range[1]) / 2;
return {
hours: Math.abs(am - mid) <= Math.abs(pm - mid) ? am : pm,
minutes: m,
};
};
/** Add a duration that might be fractional, e.g. 1.5 hours becomes 90 minutes. */
export const addFractionalSafe = (date, unit, amount) => {
if (Number.isInteger(amount)) return add(date, { [unit]: amount });
if (amount % 1 !== 0.5) return null;
const conv = FRACTIONAL_CONVERT[unit];
if (conv) return add(date, { [conv.unit]: Math.round(amount * conv.factor) });
return add(date, { [unit]: Math.round(amount) });
};
@@ -7,11 +7,17 @@ import {
startOfMonth,
isMonday,
isToday,
isSameYear,
setHours,
setMinutes,
setSeconds,
} from 'date-fns';
import wootConstants from 'dashboard/constants/globals';
import {
generateDateSuggestions,
parseDateFromText,
} from 'dashboard/helper/snoozeDateParser';
import { UNIT_MAP } from 'dashboard/helper/snoozeDateParser/tokenMaps';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -33,65 +39,113 @@ export const findStartOfNextMonth = currentDate => {
});
};
export const findNextDay = currentDate => {
return add(currentDate, { days: 1 });
};
export const findNextDay = currentDate => add(currentDate, { days: 1 });
export const setHoursToNine = date => {
return setSeconds(setMinutes(setHours(date, 9), 0), 0);
export const setHoursToNine = date =>
setSeconds(setMinutes(setHours(date, 9), 0), 0);
const SNOOZE_RESOLVERS = {
[SNOOZE_OPTIONS.AN_HOUR_FROM_NOW]: d => add(d, { hours: 1 }),
[SNOOZE_OPTIONS.UNTIL_TOMORROW]: d => setHoursToNine(findNextDay(d)),
[SNOOZE_OPTIONS.UNTIL_NEXT_WEEK]: d => setHoursToNine(findStartOfNextWeek(d)),
[SNOOZE_OPTIONS.UNTIL_NEXT_MONTH]: d =>
setHoursToNine(findStartOfNextMonth(d)),
};
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
let parsedDate = null;
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
parsedDate = add(currentDate, { hours: 1 });
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
parsedDate = setHoursToNine(findNextDay(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
}
return parsedDate ? getUnixTime(parsedDate) : null;
const resolve = SNOOZE_RESOLVERS[snoozeType];
return resolve ? getUnixTime(resolve(currentDate)) : null;
};
export const snoozedReopenTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
if (!snoozedUntil) return null;
const date = new Date(snoozedUntil);
if (isToday(date)) return format(date, 'h.mmaaa');
if (!isSameYear(date, new Date())) return format(date, 'd MMM yyyy, h.mmaaa');
return format(date, 'd MMM, h.mmaaa');
};
if (isToday(date)) {
return format(date, 'h.mmaaa');
export const snoozedReopenTimeToTimestamp = snoozedUntil =>
snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
const formatSnoozeDate = (snoozeDate, currentDate, locale = 'en') => {
const sameYear = isSameYear(snoozeDate, currentDate);
try {
const opts = {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: '2-digit',
hour12: true,
...(sameYear ? {} : { year: 'numeric' }),
};
return new Intl.DateTimeFormat(locale, opts).format(snoozeDate);
} catch {
return sameYear
? format(snoozeDate, 'EEE, d MMM, h:mm a')
: format(snoozeDate, 'EEE, d MMM yyyy, h:mm a');
}
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
};
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
const expandUnit = (num, abbr) => {
const full = UNIT_MAP[abbr];
if (!full) return `${num} ${abbr}`;
return parseFloat(num) === 1
? `${num} ${full.replace(/s$/, '')}`
: `${num} ${full}`;
};
const capitalizeLabel = text => {
const expanded = text
.replace(
/^(\d+)h(\d+)m(?:in)?$/i,
(_, h, m) => `${expandUnit(h, 'h')} ${expandUnit(m, 'm')}`
)
.replace(/^(\d+(?:\.5)?)\s*([a-z]+)$/i, (_, n, u) =>
UNIT_MAP[u.toLowerCase()] ? expandUnit(n, u.toLowerCase()) : `${n} ${u}`
);
return expanded.replace(/^\w/, c => c.toUpperCase());
};
export const generateSnoozeSuggestions = (
searchText,
currentDate = new Date(),
{ translations, locale } = {}
) => {
const suggestions = generateDateSuggestions(searchText, currentDate, {
translations,
locale,
});
return suggestions.map(s => ({
date: s.date,
unixTime: s.unix,
query: s.query,
label: capitalizeLabel(s.label),
formattedDate: formatSnoozeDate(s.date, currentDate, locale),
resolve: () => parseDateFromText(s.query)?.unix ?? s.unix,
}));
};
const UNIT_SHORT = {
minute: 'm',
minutes: 'm',
hour: 'h',
hours: 'h',
day: 'd',
days: 'd',
month: 'mo',
months: 'mo',
year: 'y',
years: 'y',
};
export const shortenSnoozeTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
const unitMap = {
minutes: 'm',
minute: 'm',
hours: 'h',
hour: 'h',
days: 'd',
day: 'd',
months: 'mo',
month: 'mo',
years: 'y',
year: 'y',
};
const shortenTime = snoozedUntil
if (!snoozedUntil) return null;
return snoozedUntil
.replace(/^in\s+/i, '')
.replace(
/\s(minute|hour|day|month|year)s?\b/gi,
(match, unit) => unitMap[unit.toLowerCase()] || match
(match, unit) => UNIT_SHORT[unit.toLowerCase()] || match
);
return shortenTime;
};
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ import {
setHoursToNine,
snoozedReopenTimeToTimestamp,
shortenSnoozeTime,
generateSnoozeSuggestions,
} from '../snoozeHelpers';
describe('#Snooze Helpers', () => {
@@ -91,12 +92,26 @@ describe('#Snooze Helpers', () => {
});
describe('snoozedReopenTime', () => {
it('should return nil if snoozedUntil is nil', () => {
expect(snoozedReopenTime(null)).toEqual(null);
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
});
it('should return formatted date if snoozedUntil is not nil', () => {
afterEach(() => {
vi.useRealTimers();
});
it('should return formatted date with year if snoozedUntil is not in current year', () => {
// Input is 09:00 UTC.
// If your environment is UTC, this will be 9.00am.
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun 2023, 9.00am'
);
});
it('should return formatted date without year if snoozedUntil is in current year', () => {
// This uses 2024 because we mocked the system time above
expect(snoozedReopenTime('2024-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am'
);
});
@@ -150,4 +165,56 @@ describe('#Snooze Helpers', () => {
expect(shortenSnoozeTime(null)).toEqual(null);
});
});
describe('generateSnoozeSuggestions label expansion', () => {
const now = new Date('2023-06-16T10:00:00');
it('expands abbreviated units: "1d" → "1 Day"', () => {
const results = generateSnoozeSuggestions('1d', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 day');
});
it('expands abbreviated units: "2 d" → "2 Days"', () => {
const results = generateSnoozeSuggestions('2 d', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 days');
});
it('expands abbreviated units: "1h" → "1 Hour"', () => {
const results = generateSnoozeSuggestions('1h', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour');
});
it('expands abbreviated units: "2min" → "2 Minutes"', () => {
const results = generateSnoozeSuggestions('2min', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 minutes');
});
it('handles singular: "1 hours" → "1 Hour"', () => {
const results = generateSnoozeSuggestions('1 hours', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour');
});
it('handles singular: "1 minutes" → "1 Minute"', () => {
const results = generateSnoozeSuggestions('1 minutes', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 minute');
});
it('keeps plural for non-1: "2 days" → "2 Days"', () => {
const results = generateSnoozeSuggestions('2 days', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 days');
});
it('expands compound: "1h30m" → "1 Hour 30 Minutes"', () => {
const results = generateSnoozeSuggestions('1h30m', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour 30 minutes');
});
});
});
@@ -182,6 +182,7 @@
},
"COMMAND_BAR": {
"SEARCH_PLACEHOLDER": "Search or jump to",
"SNOOZE_PLACEHOLDER": "Type a time e.g. tomorrow, 2 hours, next friday, jan 15...",
"SECTIONS": {
"GENERAL": "General",
"REPORTS": "Reports",
@@ -34,6 +34,7 @@ import setNewPassword from './setNewPassword.json';
import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
@@ -77,6 +78,7 @@ export default {
...settings,
...signup,
...sla,
...snooze,
...teamsSettings,
...whatsappTemplates,
...contentTemplates,
@@ -0,0 +1,72 @@
{
"SNOOZE_PARSER": {
"UNITS": {
"MINUTE": "minute",
"MINUTES": "minutes",
"HOUR": "hour",
"HOURS": "hours",
"DAY": "day",
"DAYS": "days",
"WEEK": "week",
"WEEKS": "weeks",
"MONTH": "month",
"MONTHS": "months",
"YEAR": "year",
"YEARS": "years"
},
"HALF": "half",
"NEXT": "next",
"THIS": "this",
"AT": "at",
"IN": "in",
"FROM_NOW": "from now",
"NEXT_YEAR": "next year",
"MERIDIEM": {
"AM": "am",
"PM": "pm"
},
"RELATIVE": {
"TOMORROW": "tomorrow",
"DAY_AFTER_TOMORROW": "day after tomorrow",
"NEXT_WEEK": "next week",
"NEXT_MONTH": "next month",
"THIS_WEEKEND": "this weekend",
"NEXT_WEEKEND": "next weekend"
},
"TIME_OF_DAY": {
"MORNING": "morning",
"AFTERNOON": "afternoon",
"EVENING": "evening",
"NIGHT": "night",
"NOON": "noon",
"MIDNIGHT": "midnight"
},
"WORD_NUMBERS": {
"ONE": "one",
"TWO": "two",
"THREE": "three",
"FOUR": "four",
"FIVE": "five",
"SIX": "six",
"SEVEN": "seven",
"EIGHT": "eight",
"NINE": "nine",
"TEN": "ten",
"TWELVE": "twelve",
"FIFTEEN": "fifteen",
"TWENTY": "twenty",
"THIRTY": "thirty"
},
"ORDINALS": {
"FIRST": "first",
"SECOND": "second",
"THIRD": "third",
"FOURTH": "fourth",
"FIFTH": "fifth"
},
"OF": "of",
"AFTER": "after",
"WEEK": "week",
"DAY": "day"
}
}
@@ -31,6 +31,8 @@ const toggleStatus = async (status, snoozedUntil) => {
const onCmdSnoozeConversation = snoozeType => {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomSnoozeModal.value = true;
} else if (typeof snoozeType === 'number') {
toggleStatus(wootConstants.STATUS_TYPE.SNOOZED, snoozeType);
} else {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
@@ -4,16 +4,29 @@ import { ref, computed, watchEffect, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useLocale } from 'shared/composables/useLocale';
import { useAppearanceHotKeys } from 'dashboard/composables/commands/useAppearanceHotKeys';
import { useInboxHotKeys } from 'dashboard/composables/commands/useInboxHotKeys';
import { useGoToCommandHotKeys } from 'dashboard/composables/commands/useGoToCommandHotKeys';
import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkActionsHotKeys';
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
import wootConstants from 'dashboard/constants/globals';
import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
GENERAL_EVENTS,
SNOOZE_EVENTS,
} from 'dashboard/helper/AnalyticsHelper/events';
import { generateSnoozeSuggestions } from 'dashboard/helper/snoozeHelpers';
import { ICON_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/icons';
import {
CMD_SNOOZE_CONVERSATION,
CMD_SNOOZE_NOTIFICATION,
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
import { emitter } from 'shared/helpers/mitt';
const store = useStore();
const { t } = useI18n();
const { t, tm } = useI18n();
const { resolvedLocale } = useLocale();
const ninjakeys = ref(null);
@@ -28,48 +41,168 @@ const { goToCommandHotKeys } = useGoToCommandHotKeys();
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const { conversationHotKeys } = useConversationHotKeys();
const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER'));
const SNOOZE_PARENT_IDS = [
'snooze_conversation',
'snooze_notification',
'bulk_action_snooze_conversation',
];
const DYNAMIC_SNOOZE_PREFIX = 'dynamic_snooze_';
const hotKeys = computed(() => [
...inboxHotKeys.value,
...goToCommandHotKeys.value,
...goToAppearanceHotKeys.value,
...bulkActionsHotKeys.value,
...conversationHotKeys.value,
]);
const CUSTOM_SNOOZE = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
const dynamicSnoozeActions = ref([]);
const currentCommandRoot = ref(null);
const placeholder = computed(() =>
SNOOZE_PARENT_IDS.includes(currentCommandRoot.value)
? t('COMMAND_BAR.SNOOZE_PLACEHOLDER')
: t('COMMAND_BAR.SEARCH_PLACEHOLDER')
);
const SNOOZE_PRESET_IDS = new Set(Object.values(wootConstants.SNOOZE_OPTIONS));
const hotKeys = computed(() => {
const allActions = [
...dynamicSnoozeActions.value,
...inboxHotKeys.value,
...goToCommandHotKeys.value,
...goToAppearanceHotKeys.value,
...bulkActionsHotKeys.value,
...conversationHotKeys.value,
];
// When dynamic NLP snooze suggestions exist, hide all preset snooze actions to avoid duplication
if (!dynamicSnoozeActions.value.length) return allActions;
return allActions.filter(
a => !SNOOZE_PRESET_IDS.has(a.id) || !SNOOZE_PARENT_IDS.includes(a.parent)
);
});
const setCommandBarData = () => {
ninjakeys.value.data = hotKeys.value;
};
const onSelected = item => {
const {
detail: { action: { title = null, section = null, id = null } = {} } = {},
} = item;
// Added this condition to prevent setting the selectedSnoozeType to null
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
selectedSnoozeType.value = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
} else {
selectedSnoozeType.value = null;
const SNOOZE_EVENT_MAP = {
snooze_conversation: CMD_SNOOZE_CONVERSATION,
snooze_notification: CMD_SNOOZE_NOTIFICATION,
bulk_action_snooze_conversation: CMD_BULK_ACTION_SNOOZE_CONVERSATION,
};
const SNOOZE_SECTION_MAP = {
snooze_conversation: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
snooze_notification: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
bulk_action_snooze_conversation: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
};
const snoozeTranslations = computed(() => {
const raw = tm('SNOOZE_PARSER');
if (!raw || typeof raw !== 'object') return {};
return JSON.parse(JSON.stringify(raw));
});
const buildDynamicSnoozeActions = (search, parentId) => {
const suggestions = generateSnoozeSuggestions(search, new Date(), {
translations: snoozeTranslations.value,
locale: resolvedLocale.value,
});
if (!suggestions.length) return [];
const busEvent = SNOOZE_EVENT_MAP[parentId];
const section = t(SNOOZE_SECTION_MAP[parentId]);
return suggestions.map((parsed, index) => ({
id: `${DYNAMIC_SNOOZE_PREFIX}${index}`,
title:
parsed.label !== parsed.formattedDate
? `${parsed.label} - ${parsed.formattedDate}`
: parsed.formattedDate,
parent: parentId,
section,
icon: ICON_SNOOZE_CONVERSATION,
keywords: search,
handler: () => {
emitter.emit(busEvent, parsed.resolve());
useTrack(SNOOZE_EVENTS.NLP_SNOOZE_APPLIED, { label: parsed.label });
},
}));
};
const resetSnoozeState = () => {
currentCommandRoot.value = null;
dynamicSnoozeActions.value = [];
};
const patchNinjaKeysOpenClose = el => {
if (!el || typeof el.open !== 'function' || typeof el.close !== 'function') {
return;
}
useTrack(GENERAL_EVENTS.COMMAND_BAR, {
section,
action: title,
});
const originalOpen = el.open.bind(el);
const originalClose = el.close.bind(el);
el.open = (...args) => {
const [options = {}] = args;
currentCommandRoot.value = options.parent || null;
dynamicSnoozeActions.value = [];
return originalOpen(...args);
};
el.close = (...args) => {
resetSnoozeState();
return originalClose(...args);
};
};
const onSelected = item => {
const {
detail: {
action: { title = null, section = null, id = null, children = null } = {},
} = {},
} = item;
selectedSnoozeType.value = id === CUSTOM_SNOOZE ? id : null;
if (Array.isArray(children) && children.length) {
currentCommandRoot.value = id;
}
useTrack(GENERAL_EVENTS.COMMAND_BAR, { section, action: title });
setCommandBarData();
};
const onClosed = () => {
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
const onCommandBarChange = item => {
const { detail: { search = '', actions = [] } = {} } = item;
const normalizedSearch = search.trim();
if (actions.length > 0) {
const uniqueParents = [
...new Set(actions.map(action => action.parent).filter(Boolean)),
];
if (uniqueParents.length === 1) {
currentCommandRoot.value = uniqueParents[0];
} else {
currentCommandRoot.value = null;
}
}
if (
selectedSnoozeType.value !== wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
!normalizedSearch ||
!SNOOZE_PARENT_IDS.includes(currentCommandRoot.value || '')
) {
dynamicSnoozeActions.value = [];
return;
}
dynamicSnoozeActions.value = buildDynamicSnoozeActions(
normalizedSearch,
currentCommandRoot.value
);
};
const onClosed = () => {
if (selectedSnoozeType.value !== CUSTOM_SNOOZE) {
store.dispatch('setContextMenuChatId', null);
}
resetSnoozeState();
};
watchEffect(() => {
@@ -78,7 +211,10 @@ watchEffect(() => {
}
});
onMounted(setCommandBarData);
onMounted(() => {
setCommandBarData();
patchNinjaKeysOpenClose(ninjakeys.value);
});
</script>
<!-- eslint-disable vue/attribute-hyphenation -->
@@ -88,6 +224,7 @@ onMounted(setCommandBarData);
noAutoLoadMdIcons
hideBreadcrumbs
:placeholder="placeholder"
@change="onCommandBarChange"
@selected="onSelected"
@closed="onClosed"
/>
@@ -69,6 +69,8 @@ export default {
onCmdSnoozeNotification(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.snoozeNotification(snoozeType);
} else {
const snoozedUntil = findSnoozeTime(snoozeType) || null;
this.snoozeNotification(snoozedUntil);
@@ -228,7 +228,10 @@ export const mutations = {
},
[types.ADD_CONVERSATION](_state, conversation) {
_state.allConversations.push(conversation);
const exists = _state.allConversations.some(c => c.id === conversation.id);
if (!exists) {
_state.allConversations.push(conversation);
}
},
[types.DELETE_CONVERSATION](_state, conversationId) {
@@ -975,6 +975,16 @@ describe('#mutations', () => {
mutations[types.ADD_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([conversation]);
});
it('should not add a duplicate conversation', () => {
const conversation = { id: 1, messages: [] };
const state = {
allConversations: [conversation],
};
mutations[types.ADD_CONVERSATION](state, { id: 1, messages: [] });
expect(state.allConversations).toHaveLength(1);
});
});
describe('#DELETE_CONVERSATION', () => {
@@ -6,7 +6,12 @@ const RECONNECT_INTERVAL = 1000;
class BaseActionCableConnector {
static isDisconnected = false;
constructor(app, pubsubToken, websocketHost = '') {
constructor(
app,
pubsubToken,
websocketHost = '',
presenceInterval = PRESENCE_INTERVAL
) {
const websocketURL = websocketHost ? `${websocketHost}/cable` : undefined;
this.consumer = createConsumer(websocketURL);
@@ -37,7 +42,7 @@ class BaseActionCableConnector {
setTimeout(() => {
this.subscription.updatePresence();
this.triggerPresenceInterval();
}, PRESENCE_INTERVAL);
}, presenceInterval);
};
this.triggerPresenceInterval();
}
+3 -1
View File
@@ -13,9 +13,11 @@ const isMessageInActiveConversation = (getters, message) => {
return activeConversationId && conversationId !== activeConversationId;
};
const WIDGET_PRESENCE_INTERVAL = 60000;
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
super(app, pubsubToken);
super(app, pubsubToken, '', WIDGET_PRESENCE_INTERVAL);
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
+7 -27
View File
@@ -1,6 +1,5 @@
class Webhooks::WhatsappEventsJob < MutexApplicationJob
class Webhooks::WhatsappEventsJob < ApplicationJob
queue_as :low
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
def perform(params = {})
channel = find_channel_from_whatsapp_business_payload(params)
@@ -10,14 +9,10 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
return
end
sender_id = extract_sender_id(params)
if sender_id
key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, sender_id: sender_id, phone_number: channel.phone_number)
with_lock(key, 30.seconds) do
process_events(channel, params)
end
if message_echo_event?(params)
handle_message_echo(channel, params)
else
process_events(channel, params)
handle_message_events(channel, params)
end
end
@@ -59,14 +54,8 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
params.dig(:entry, 0, :changes, 0, :field) == 'smb_message_echoes'
end
private
def process_events(channel, params)
if message_echo_event?(params)
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
else
handle_message_events(channel, params)
end
def handle_message_echo(channel, params)
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: channel.inbox, params: params, outgoing_echo: true).perform
end
def handle_message_events(channel, params)
@@ -78,16 +67,7 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
end
end
# Extracts the contact's phone number from the webhook payload for use as a mutex key.
# For incoming messages, the contact is the sender (from field).
# For echo messages, the contact is the recipient (to field).
# For status updates, returns nil (no lock needed as they don't create conversations).
def extract_sender_id(params)
value = params.dig(:entry, 0, :changes, 0, :value)
return unless value
value.dig(:messages, 0, :from) || value.dig(:contacts, 0, :wa_id) || value.dig(:message_echoes, 0, :to)
end
private
def channel_is_inactive?(channel)
return true if channel.blank?
@@ -20,6 +20,7 @@ class ReportingEventListener < BaseListener
create_bot_resolved_event(conversation, reporting_event)
reporting_event.save!
ReportingEvents::RollupService.perform(reporting_event)
end
def first_reply_created(event)
@@ -41,6 +42,7 @@ class ReportingEventListener < BaseListener
)
reporting_event.save!
ReportingEvents::RollupService.perform(reporting_event)
end
def reply_created(event)
@@ -65,6 +67,7 @@ class ReportingEventListener < BaseListener
event_end_time: message.created_at
)
reporting_event.save!
ReportingEvents::RollupService.perform(reporting_event)
end
def conversation_bot_handoff(event)
@@ -88,6 +91,7 @@ class ReportingEventListener < BaseListener
event_end_time: conversation.updated_at
)
reporting_event.save!
ReportingEvents::RollupService.perform(reporting_event)
end
def conversation_opened(event)
@@ -139,5 +143,6 @@ class ReportingEventListener < BaseListener
bot_resolved_event = reporting_event.dup
bot_resolved_event.name = 'conversation_bot_resolved'
bot_resolved_event.save!
ReportingEvents::RollupService.new(bot_resolved_event).perform
end
end
+8
View File
@@ -85,11 +85,13 @@ class Account < ApplicationRecord
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings }
validate :validate_reporting_timezone
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :reporting_timezone
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :captain_disable_auto_resolve
@@ -214,6 +216,12 @@ class Account < ApplicationRecord
# method overridden in enterprise module
end
def validate_reporting_timezone
return if reporting_timezone.blank? || ActiveSupport::TimeZone[reporting_timezone].present?
errors.add(:reporting_timezone, I18n.t('errors.account.reporting_timezone.invalid'))
end
def remove_account_sequences
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS camp_dpid_seq_#{id}")
ActiveRecord::Base.connection.exec_query("drop sequence IF EXISTS conv_dpid_seq_#{id}")
+8 -1
View File
@@ -30,10 +30,12 @@ class CustomAttributeDefinition < ApplicationRecord
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
validates :attribute_display_name, presence: true
before_validation :normalize_attribute_fields
validates :attribute_key,
presence: true,
uniqueness: { scope: [:account_id, :attribute_model] }
uniqueness: { scope: [:account_id, :attribute_model] },
format: { with: /\A[\p{L}\p{N}_.\-]+\z/, message: I18n.t('errors.custom_attribute_definition.attribute_key_format') }
validates :attribute_display_type, presence: true
validates :attribute_model, presence: true
@@ -48,6 +50,11 @@ class CustomAttributeDefinition < ApplicationRecord
private
def normalize_attribute_fields
self.attribute_key = attribute_key.strip if attribute_key.present?
self.attribute_display_name = attribute_display_name.strip if attribute_display_name.present?
end
def sync_widget_pre_chat_custom_fields
::Inboxes::SyncWidgetPreChatCustomFieldsJob.perform_later(account, attribute_key)
end
+48
View File
@@ -0,0 +1,48 @@
# == Schema Information
#
# Table name: reporting_events_rollups
#
# id :bigint not null, primary key
# count :bigint default(0), not null
# date :date not null
# dimension_id :bigint not null
# dimension_type :string not null
# metric :string not null
# sum_value :float default(0.0), not null
# sum_value_business_hours :float default(0.0), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_rollup_summary (account_id,dimension_type,date)
# index_rollup_timeseries (account_id,metric,date)
# index_rollup_unique_key (account_id,date,dimension_type,dimension_id,metric) UNIQUE
#
class ReportingEventsRollup < ApplicationRecord
belongs_to :account
# Store string values directly in the database for better readability and debugging
enum :dimension_type, %w[account agent inbox team].index_by(&:itself)
enum :metric, %w[
resolutions_count
first_response
resolution_time
reply_time
bot_resolutions_count
bot_handoffs_count
].index_by(&:itself)
validates :account_id, presence: true
validates :date, presence: true
validates :dimension_type, presence: true
validates :dimension_id, presence: true
validates :metric, presence: true
validates :count, numericality: { greater_than_or_equal_to: 0 }
scope :for_date_range, ->(start_date, end_date) { where(date: start_date..end_date) }
scope :for_dimension, ->(type, id) { where(dimension_type: type, dimension_id: id) }
scope :for_metric, ->(metric) { where(metric: metric) }
end
+47 -13
View File
@@ -33,9 +33,9 @@ class FilterService
when 'is_not_present'
@filter_values["value_#{current_index}"] = 'IS NULL'
when 'is_greater_than', 'is_less_than'
@filter_values["value_#{current_index}"] = lt_gt_filter_values(query_hash)
lt_gt_filter_query(query_hash, current_index)
when 'days_before'
@filter_values["value_#{current_index}"] = days_before_filter_values(query_hash)
days_before_filter_query(query_hash, current_index)
else
@filter_values["value_#{current_index}"] = filter_values(query_hash).to_s
"= :value_#{current_index}"
@@ -81,21 +81,29 @@ class FilterService
query_hash['values'].downcase
end
def lt_gt_filter_values(query_hash)
def lt_gt_filter_query(query_hash, current_index)
attribute_key = query_hash[:attribute_key]
attribute_model = query_hash['custom_attribute_type'].presence || self.class::ATTRIBUTE_MODEL
attribute_type = custom_attribute(attribute_key, @account, attribute_model).try(:attribute_display_type)
attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
value = query_hash['values'][0]
attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type] || standard_attribute_data_type(attribute_key)
@filter_values["value_#{current_index}"] = coerce_lt_gt_value(
query_hash['values'][0],
attribute_data_type,
attribute_key
)
operator = query_hash['filter_operator'] == 'is_less_than' ? '<' : '>'
"#{operator} '#{value}'::#{attribute_data_type}"
"#{operator} :value_#{current_index}"
end
def days_before_filter_values(query_hash)
def days_before_filter_query(query_hash, current_index)
date = Time.zone.today - query_hash['values'][0].to_i.days
query_hash['values'] = [date.strftime]
query_hash['filter_operator'] = 'is_less_than'
lt_gt_filter_values(query_hash)
updated_query_hash = query_hash.with_indifferent_access.merge(
values: [date.strftime],
filter_operator: 'is_less_than'
)
lt_gt_filter_query(updated_query_hash, current_index)
end
def set_count_for_all_conversations
@@ -149,15 +157,39 @@ class FilterService
@attribute_data_type = self.class::ATTRIBUTE_TYPES[attribute_type]
end
def standard_attribute_data_type(attribute_key)
@filters.each_value do |section|
return section.dig(attribute_key, 'data_type') if section.is_a?(Hash) && section.key?(attribute_key)
end
nil
end
def coerce_lt_gt_value(raw_value, attribute_data_type, attribute_key)
case attribute_data_type
when 'date'
Date.iso8601(raw_value.to_s)
when 'numeric'
BigDecimal(raw_value.to_s)
else
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
end
rescue Date::Error, ArgumentError, FloatDomainError, TypeError
raise CustomExceptions::CustomFilter::InvalidValue.new(attribute_name: attribute_key)
end
def build_custom_attr_query(query_hash, current_index)
filter_operator_value = filter_operation(query_hash, current_index)
query_operator = query_hash[:query_operator]
table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts'
query = if attribute_data_type == 'text'
"LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
else
"(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} "
ActiveRecord::Base.sanitize_sql_array(
["(#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} #{filter_operator_value} #{query_operator} ", @attribute_key]
)
end
query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
@@ -174,7 +206,9 @@ class FilterService
def not_in_custom_attr_query(table_name, query_hash, attribute_data_type)
return '' unless query_hash[:filter_operator] == 'not_equal_to'
" OR (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} IS NULL "
ActiveRecord::Base.sanitize_sql_array(
[" OR (#{table_name}.custom_attributes ->> ?)::#{attribute_data_type} IS NULL ", @attribute_key]
)
end
def equals_to_filter_string(filter_operator, current_index)
@@ -3,7 +3,7 @@ class Internal::RemoveStaleRedisKeysService
def perform
Rails.logger.info "Removing redis stale keys for account #{@account_id}"
range_start = (Time.zone.now - OnlineStatusTracker::PRESENCE_DURATION).to_i
range_start = (Time.zone.now - OnlineStatusTracker::CONTACT_PRESENCE_DURATION).to_i
# exclusive minimum score is specified by prefixing (
# we are clearing old records because this could clogg up the sorted set
::Redis::Alfred.zremrangebyscore(
@@ -0,0 +1,130 @@
# frozen_string_literal: true
class ReportingEvents::BackfillService
def self.backfill_date(account, date)
new(account, date).perform
end
def initialize(account, date)
@account = account
@date = date
end
def perform
# 1. Delete existing rollups for this date (idempotency)
delete_existing_rollups
# 2. Convert date in account timezone to UTC boundaries
start_utc, end_utc = date_boundaries_in_utc
# 3. Process each event type with SQL aggregation
rollup_rows = []
rollup_rows.concat(aggregate_event_type('conversation_resolved', start_utc, end_utc))
rollup_rows.concat(aggregate_event_type('first_response', start_utc, end_utc))
rollup_rows.concat(aggregate_event_type('reply_time', start_utc, end_utc))
rollup_rows.concat(aggregate_event_type('conversation_bot_resolved', start_utc, end_utc))
rollup_rows.concat(aggregate_event_type('conversation_bot_handoff', start_utc, end_utc))
# 4. Bulk insert all rollups at once
bulk_insert_rollups(rollup_rows) if rollup_rows.any?
end
private
def delete_existing_rollups
ReportingEventsRollup.where(account_id: @account.id, date: @date).delete_all
end
def date_boundaries_in_utc
tz = ActiveSupport::TimeZone[@account.reporting_timezone]
start_in_tz = tz.parse(@date.to_s)
end_in_tz = start_in_tz + 1.day
[start_in_tz.utc, end_in_tz.utc]
end
def aggregate_event_type(event_name, start_utc, end_utc)
events = @account.reporting_events
.where(name: event_name, created_at: start_utc...end_utc)
.includes(:conversation)
return [] if events.empty?
# Build in-memory aggregates by dimension
aggregates = build_aggregates(events, event_name)
# Convert to rollup row hashes
aggregates.map do |(dimension_type, dimension_id, metric), data|
{
account_id: @account.id,
date: @date,
dimension_type: dimension_type,
dimension_id: dimension_id,
metric: metric,
count: data[:count],
sum_value: data[:sum_value],
sum_value_business_hours: data[:sum_value_business_hours],
created_at: Time.current,
updated_at: Time.current
}
end
end
def build_aggregates(events, event_name)
aggregates = Hash.new { |h, k| h[k] = { count: 0, sum_value: 0.0, sum_value_business_hours: 0.0 } }
events.each do |event|
metrics = metrics_for_event(event_name, event)
dimensions = dimensions_for_event(event)
metrics.each do |metric, metric_data|
dimensions.each do |dimension_type, dimension_id|
next if dimension_id.nil?
key = [dimension_type, dimension_id, metric]
aggregates[key][:count] += metric_data[:count]
aggregates[key][:sum_value] += metric_data[:sum_value].to_f
aggregates[key][:sum_value_business_hours] += metric_data[:sum_value_business_hours].to_f
end
end
end
aggregates
end
def dimensions_for_event(event)
{
'account' => @account.id,
'agent' => event.user_id,
'inbox' => event.inbox_id,
# Team rollups are still collected for validation/future use, but team reports
# currently stay on the raw path because reassignment breaks rollup parity.
'team' => event.conversation&.team_id
}
end
def metrics_for_event(event_name, event)
case event_name
when 'conversation_resolved'
{
'resolutions_count' => { count: 1, sum_value: 0, sum_value_business_hours: 0 },
'resolution_time' => { count: 1, sum_value: event.value, sum_value_business_hours: event.value_in_business_hours }
}
when 'first_response'
{ 'first_response' => { count: 1, sum_value: event.value, sum_value_business_hours: event.value_in_business_hours } }
when 'reply_time'
{ 'reply_time' => { count: 1, sum_value: event.value, sum_value_business_hours: event.value_in_business_hours } }
when 'conversation_bot_resolved'
{ 'bot_resolutions_count' => { count: 1, sum_value: 0, sum_value_business_hours: 0 } }
when 'conversation_bot_handoff'
{ 'bot_handoffs_count' => { count: 1, sum_value: 0, sum_value_business_hours: 0 } }
else
{}
end
end
def bulk_insert_rollups(rollup_rows)
# rubocop:disable Rails/SkipsModelValidations
ReportingEventsRollup.insert_all(rollup_rows)
# rubocop:enable Rails/SkipsModelValidations
end
end
@@ -0,0 +1,149 @@
module ReportingEvents::RollupService
def self.perform(reporting_event)
new(reporting_event).perform
end
def self.new(reporting_event)
Performer.new(reporting_event)
end
class Performer
def initialize(reporting_event)
@reporting_event = reporting_event
@account = reporting_event.account
end
def perform
return unless rollup_enabled?
# NOTE: Each event produces individual upserts per dimension x metric (up to 8 calls).
# If this becomes a bottleneck, batch into a single upsert_all call.
dimensions.each do |dimension_type, dimension_id|
next if dimension_id.nil?
metrics_for_event.each do |metric, metric_data|
upsert_rollup(dimension_type, dimension_id, metric, metric_data)
end
end
end
private
# NOTE: This is intentionally not gated by the reporting_events_rollup feature flag.
# Rollup data is collected for all accounts with a valid reporting timezone (soft toggle).
# The feature flag only controls the read path — whether reports query rollups or raw events.
def rollup_enabled?
@account.reporting_timezone.present? && ActiveSupport::TimeZone[@account.reporting_timezone].present?
end
def event_date
@event_date ||= @reporting_event.created_at.in_time_zone(@account.reporting_timezone).to_date
end
def dimensions
{
account: @account.id,
agent: @reporting_event.user_id,
inbox: @reporting_event.inbox_id,
# Team rollups are still collected for validation/future use, but team reports
# currently stay on the raw path because reassignment breaks rollup parity.
team: team_id
}
end
def team_id
return nil if @reporting_event.conversation_id.blank?
@team_id ||= @reporting_event.conversation&.team_id
end
def metrics_for_event
case @reporting_event.name
when 'conversation_resolved'
conversation_resolved_metrics
when 'first_response'
first_response_metrics
when 'reply_time'
reply_time_metrics
when 'conversation_bot_resolved'
bot_resolutions_metrics
when 'conversation_bot_handoff'
bot_handoffs_metrics
else
{}
end
end
def conversation_resolved_metrics
{
resolutions_count: { count: 1, sum_value: 0, sum_value_business_hours: 0 },
resolution_time: {
count: 1,
sum_value: @reporting_event.value,
sum_value_business_hours: @reporting_event.value_in_business_hours
}
}
end
def first_response_metrics
{
first_response: {
count: 1,
sum_value: @reporting_event.value,
sum_value_business_hours: @reporting_event.value_in_business_hours
}
}
end
def reply_time_metrics
{
reply_time: {
count: 1,
sum_value: @reporting_event.value,
sum_value_business_hours: @reporting_event.value_in_business_hours
}
}
end
def bot_resolutions_metrics
{
bot_resolutions_count: { count: 1, sum_value: 0, sum_value_business_hours: 0 }
}
end
def bot_handoffs_metrics
{
bot_handoffs_count: { count: 1, sum_value: 0, sum_value_business_hours: 0 }
}
end
def upsert_rollup(dimension_type, dimension_id, metric, metric_data)
# rubocop:disable Rails/SkipsModelValidations
ReportingEventsRollup.upsert(
rollup_attributes(dimension_type, dimension_id, metric, metric_data),
unique_by: [:account_id, :date, :dimension_type, :dimension_id, :metric],
on_duplicate: upsert_on_duplicate_sql
)
# rubocop:enable Rails/SkipsModelValidations
end
def rollup_attributes(dimension_type, dimension_id, metric, metric_data)
{
account_id: @account.id, date: event_date,
dimension_type: dimension_type, dimension_id: dimension_id, metric: metric,
count: metric_data[:count], sum_value: metric_data[:sum_value].to_f,
sum_value_business_hours: metric_data[:sum_value_business_hours].to_f,
created_at: Time.current, updated_at: Time.current
}
end
def upsert_on_duplicate_sql
Arel.sql(
'count = reporting_events_rollups.count + EXCLUDED.count, ' \
'sum_value = reporting_events_rollups.sum_value + EXCLUDED.sum_value, ' \
'sum_value_business_hours = reporting_events_rollups.sum_value_business_hours + EXCLUDED.sum_value_business_hours, ' \
'updated_at = EXCLUDED.updated_at'
)
end
end
end
@@ -0,0 +1,8 @@
<div class="field-unit__label">
<%= f.label field.attribute %>
</div>
<div class="field-unit__field">
<% value = field.data %>
<% value = Time.current if value.blank? && action_name == 'new' %>
<%= f.datetime_local_field field.attribute, step: 1, value: value %>
</div>
@@ -53,6 +53,14 @@ as well as a link to its edit page.
<% end %>
</fieldset>
<% end %>
<dt class="attribute-label" id="mfa_enabled">MFA</dt>
<dd class="attribute-data attribute-data--string">
<% mfa_enabled = page.resource.mfa_enabled? %>
<span class="<%= mfa_enabled ? 'bg-green-100/70 text-green-800' : 'bg-slate-100 text-slate-700' %> inline-flex items-center px-2 h-4 leading-4 rounded-xl text-xxs font-medium">
<%= mfa_enabled ? 'Enabled' : 'Disabled' %>
</span>
</dd>
</dl>
</section>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.11.1'
version: '4.11.2'
development:
<<: *shared
+3
View File
@@ -242,3 +242,6 @@
display_name: Advanced Assignment
enabled: false
premium: true
- name: reporting_events_rollup
display_name: Reporting Events Rollup
enabled: false
+4
View File
@@ -48,6 +48,9 @@ en:
inbox_deletetion_response: Your inbox deletion request will be processed in some time.
errors:
account:
reporting_timezone:
invalid: is not a valid timezone
validations:
presence: must not be blank
webhook:
@@ -122,6 +125,7 @@ en:
invalid_query_operator: Query operator must be either "AND" or "OR".
invalid_value: Invalid value. The values provided for %{attribute_name} are invalid
custom_attribute_definition:
attribute_key_format: must only contain letters, numbers, underscores, hyphens, and dots
key_conflict: The provided key is not allowed as it might conflict with default attributes.
mfa:
already_enabled: MFA is already enabled
@@ -0,0 +1,39 @@
class CreateReportingEventsRollup < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
create_rollups_table
add_rollups_indexes
end
private
def create_rollups_table
create_table :reporting_events_rollups do |t|
t.integer :account_id, null: false
t.date :date, null: false
t.string :dimension_type, null: false
t.bigint :dimension_id, null: false
t.string :metric, null: false
t.bigint :count, default: 0, null: false
t.float :sum_value, default: 0.0, null: false
t.float :sum_value_business_hours, default: 0.0, null: false
t.timestamps
end
end
def add_rollups_indexes
add_index :reporting_events_rollups,
[:account_id, :date, :dimension_type, :dimension_id, :metric],
unique: true, name: 'index_rollup_unique_key', algorithm: :concurrently
add_index :reporting_events_rollups,
[:account_id, :metric, :date],
name: 'index_rollup_timeseries', algorithm: :concurrently
add_index :reporting_events_rollups,
[:account_id, :dimension_type, :date],
name: 'index_rollup_summary', algorithm: :concurrently
end
end
+16
View File
@@ -1124,6 +1124,22 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_153427) do
t.index ["user_id"], name: "index_reporting_events_on_user_id"
end
create_table "reporting_events_rollups", force: :cascade do |t|
t.integer "account_id", null: false
t.date "date", null: false
t.string "dimension_type", null: false
t.bigint "dimension_id", null: false
t.string "metric", null: false
t.bigint "count", default: 0, null: false
t.float "sum_value", default: 0.0, null: false
t.float "sum_value_business_hours", default: 0.0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "date", "dimension_type", "dimension_id", "metric"], name: "index_rollup_unique_key", unique: true
t.index ["account_id", "dimension_type", "date"], name: "index_rollup_summary"
t.index ["account_id", "metric", "date"], name: "index_rollup_timeseries"
end
create_table "sla_events", force: :cascade do |t|
t.bigint "applied_sla_id", null: false
t.bigint "conversation_id", null: false
+4
View File
@@ -14,4 +14,8 @@ class GlobalConfigService
GlobalConfig.clear_cache
i.value
end
def self.account_signup_enabled?
load('ENABLE_ACCOUNT_SIGNUP', 'false').to_s != 'false'
end
end
+5 -2
View File
@@ -1,6 +1,8 @@
class OnlineStatusTracker
# NOTE: You can customise the environment variable to keep your agents/contacts as online for longer
PRESENCE_DURATION = ENV.fetch('PRESENCE_DURATION', 20).to_i.seconds
# Widget pings every 60s, so contacts need a longer presence window
CONTACT_PRESENCE_DURATION = ENV.fetch('CONTACT_PRESENCE_DURATION', 90).to_i.seconds
# presence : sorted set with timestamp as the score & object id as value
@@ -11,7 +13,8 @@ class OnlineStatusTracker
def self.get_presence(account_id, obj_type, obj_id)
connected_time = ::Redis::Alfred.zscore(presence_key(account_id, obj_type), obj_id)
connected_time && connected_time > (Time.zone.now - PRESENCE_DURATION).to_i
duration = obj_type == 'Contact' ? CONTACT_PRESENCE_DURATION : PRESENCE_DURATION
connected_time && connected_time > (Time.zone.now - duration).to_i
end
def self.presence_key(account_id, type)
@@ -39,7 +42,7 @@ class OnlineStatusTracker
end
def self.get_available_contact_ids(account_id)
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
range_start = (Time.zone.now - CONTACT_PRESENCE_DURATION).to_i
# exclusive minimum score is specified by prefixing (
# we are clearing old records because this could clogg up the sorted set
::Redis::Alfred.zremrangebyscore(presence_key(account_id, 'Contact'), '-inf', "(#{range_start}")
-1
View File
@@ -41,7 +41,6 @@ module Redis::RedisKeys
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
TIKTOK_MESSAGE_MUTEX = 'TIKTOK_MESSAGE_CREATE_LOCK::%<business_id>s::%<conversation_id>s'.freeze
TIKTOK_REFRESH_TOKEN_MUTEX = 'TIKTOK_REFRESH_TOKEN_LOCK::%<channel_id>s'.freeze
WHATSAPP_MESSAGE_MUTEX = 'WHATSAPP_MESSAGE_CREATE_LOCK::%<sender_id>s::%<phone_number>s'.freeze
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
+251
View File
@@ -0,0 +1,251 @@
# frozen_string_literal: true
namespace :reporting_events_rollup do
desc 'Backfill rollup table from historical reporting events'
task backfill: :environment do
ReportingEventsRollupBackfill.new.run
end
end
class ReportingEventsRollupBackfill # rubocop:disable Metrics/ClassLength
def run
print_header
account = prompt_account
timezone = resolve_timezone(account)
first_event, last_event = discover_events(account)
start_date, end_date, total_days = resolve_date_range(account, timezone, first_event, last_event)
dry_run = prompt_dry_run?
print_plan(account, timezone, start_date, end_date, total_days, first_event, last_event, dry_run)
return if dry_run
confirm_and_execute(account, start_date, end_date, total_days)
end
private
def print_header
puts ''
puts color('=' * 70, :cyan)
puts color('Reporting Events Rollup Backfill', :bold, :cyan)
puts color('=' * 70, :cyan)
puts color('Plan:', :bold, :yellow)
puts '1. Ensure account.reporting_timezone is set before running this task.'
puts '2. Wait for the current day to end in that account timezone.'
puts '3. Run backfill for closed days only (today is skipped by default).'
puts '4. Verify parity, then enable reporting_events_rollup read path.'
puts ''
puts color('Note:', :bold, :yellow)
puts '- This task always uses account.reporting_timezone.'
puts '- Default range is first event day -> yesterday (in account timezone).'
puts ''
end
def prompt_account
print 'Enter Account ID: '
account_id = $stdin.gets.chomp
abort color('Error: Account ID is required', :red, :bold) if account_id.blank?
account = Account.find_by(id: account_id)
abort color("Error: Account with ID #{account_id} not found", :red, :bold) unless account
puts color("Found account: #{account.name}", :gray)
puts ''
account
end
def resolve_timezone(account)
timezone = account.reporting_timezone
abort color("Error: Account #{account.id} must have reporting_timezone set", :red, :bold) if timezone.blank?
abort color("Error: Account #{account.id} has invalid reporting_timezone '#{timezone}'", :red, :bold) if ActiveSupport::TimeZone[timezone].blank?
puts color("Using account reporting timezone: #{timezone}", :gray)
puts ''
timezone
end
def discover_events(account)
first_event = account.reporting_events.order(:created_at).first
last_event = account.reporting_events.order(:created_at).last
if first_event.nil?
puts ''
puts "No reporting events found for account #{account.id}"
puts 'Nothing to backfill.'
exit(0)
end
[first_event, last_event]
end
def resolve_date_range(account, timezone, first_event, last_event)
dates = discovered_dates(timezone, first_event, last_event)
print_discovered_date_range(account, dates)
build_date_range(dates)
end
def prompt_dry_run?
print 'Dry run? (y/N): '
input = $stdin.gets.chomp.downcase
puts ''
%w[y yes].include?(input)
end
# rubocop:disable Metrics/ParameterLists
def print_plan(account, timezone, start_date, end_date, total_days, first_event, last_event, dry_run)
zone = ActiveSupport::TimeZone[timezone]
print_plan_summary(account, timezone, start_date, end_date, total_days, zone, first_event, last_event, dry_run)
return unless dry_run
puts color("DRY RUN MODE: Would process #{total_days} days", :yellow, :bold)
puts "Would use account reporting_timezone '#{timezone}'"
puts 'Run without dry run to execute backfill'
end
# rubocop:enable Metrics/ParameterLists
def print_plan_summary(account, timezone, start_date, end_date, total_days, zone, first_event, last_event, dry_run) # rubocop:disable Metrics/ParameterLists
puts color('=' * 70, :cyan)
puts color('Backfill Plan Summary', :bold, :cyan)
puts color('=' * 70, :cyan)
puts "Account: #{account.name} (ID: #{account.id})"
puts "Timezone: #{timezone}"
puts "Date Range: #{start_date} to #{end_date} (#{total_days} days)"
puts "First Event: #{format_event_time(first_event, zone)}"
puts "Last Event: #{format_event_time(last_event, zone)}"
puts "Dry Run: #{dry_run ? 'YES (no data will be written)' : 'NO'}"
puts color('=' * 70, :cyan)
puts ''
end
def format_event_time(event, zone)
event.created_at.in_time_zone(zone).strftime('%Y-%m-%d %H:%M:%S %Z')
end
def discovered_dates(timezone, first_event, last_event)
tz = ActiveSupport::TimeZone[timezone]
discovered_start = first_event.created_at.in_time_zone(tz).to_date
discovered_end = last_event.created_at.in_time_zone(tz).to_date
{
discovered_start: discovered_start,
discovered_end: discovered_end,
discovered_days: (discovered_end - discovered_start).to_i + 1,
default_end: [discovered_end, Time.current.in_time_zone(tz).to_date - 1.day].min
}
end
def print_discovered_date_range(account, dates)
message = "Discovered date range: #{dates[:discovered_start]} to #{dates[:discovered_end]} " \
"(#{dates[:discovered_days]} days) [Account: #{account.name}]"
puts color(message, :gray)
puts color("Default end date (excluding today): #{dates[:default_end]}", :gray)
puts ''
end
def build_date_range(dates)
start_date = dates[:discovered_start]
end_date = dates[:default_end]
total_days = (end_date - start_date).to_i + 1
abort_no_closed_days if total_days <= 0
[start_date, end_date, total_days]
end
def abort_no_closed_days
puts 'No closed days available to backfill in the default range.'
exit(0)
end
def confirm_and_execute(account, start_date, end_date, total_days)
if total_days > 730
puts color("WARNING: Large backfill detected (#{total_days} days / #{(total_days / 365.0).round(1)} years)", :yellow, :bold)
puts ''
end
print 'Proceed with backfill? (y/N): '
confirm = $stdin.gets.chomp.downcase
abort 'Backfill cancelled' unless %w[y yes].include?(confirm)
puts ''
execute_backfill(account, start_date, end_date, total_days)
end
def execute_backfill(account, start_date, end_date, total_days)
puts 'Processing dates...'
puts ''
start_time = Time.current
days_processed = 0
(start_date..end_date).each do |date|
ReportingEvents::BackfillService.backfill_date(account, date)
days_processed += 1
percentage = (days_processed.to_f / total_days * 100).round(1)
print "\r#{date} | #{days_processed}/#{total_days} days | #{percentage}% "
$stdout.flush
end
print_success(account, days_processed, total_days, Time.current - start_time)
rescue StandardError => e
print_failure(e, days_processed, total_days)
end
def print_success(account, days_processed, _total_days, elapsed_time)
puts "\n\n"
puts color('=' * 70, :green)
puts color('BACKFILL COMPLETE', :bold, :green)
puts color('=' * 70, :green)
puts "Total Days Processed: #{days_processed}"
puts "Total Time: #{elapsed_time.round(2)} seconds"
puts "Average per Day: #{(elapsed_time / days_processed).round(3)} seconds"
puts ''
puts 'Next steps:'
puts "1. Enable feature flag: Account.find(#{account.id}).enable_features!('reporting_events_rollup')"
puts '2. Verify rollups in database:'
puts " ReportingEventsRollup.where(account_id: #{account.id}).count"
puts '3. Test reports to compare rollup vs raw performance'
puts color('=' * 70, :green)
end
def print_failure(error, days_processed, total_days)
puts "\n\n"
puts color('=' * 70, :red)
puts color('BACKFILL FAILED', :bold, :red)
puts color('=' * 70, :red)
print_error_details(error)
print_progress(days_processed, total_days)
exit(1)
end
def print_error_details(error)
puts color("Error: #{error.class.name} - #{error.message}", :red, :bold)
puts ''
puts 'Stack trace:'
puts error.backtrace.first(10).map { |line| " #{line}" }.join("\n")
puts ''
end
def print_progress(days_processed, total_days)
percentage = (days_processed.to_f / total_days * 100).round(1)
puts "Processed: #{days_processed}/#{total_days} days (#{percentage}%)"
puts color('=' * 70, :red)
end
ANSI_COLORS = {
reset: "\e[0m",
bold: "\e[1m",
red: "\e[31m",
green: "\e[32m",
yellow: "\e[33m",
cyan: "\e[36m",
gray: "\e[90m"
}.freeze
def color(text, *styles)
return text unless $stdout.tty?
codes = styles.filter_map { |style| ANSI_COLORS[style] }.join
"#{codes}#{text}#{ANSI_COLORS[:reset]}"
end
end
@@ -0,0 +1,175 @@
# frozen_string_literal: true
namespace :reporting_events_rollup do
desc 'Interactively set account.reporting_timezone and show recommended backfill run times'
task set_timezone: :environment do
ReportingEventsRollupTimezoneSetup.new.run
end
end
class ReportingEventsRollupTimezoneSetup
def run
print_header
account = prompt_account
print_current_timezone(account)
timezone = prompt_timezone
confirm_and_update(account, timezone)
print_next_steps(account, timezone)
end
private
def print_header
puts ''
puts color('=' * 70, :cyan)
puts color('Reporting Events Rollup Timezone Setup', :bold, :cyan)
puts color('=' * 70, :cyan)
puts color('Help:', :bold, :yellow)
puts '1. This task writes a valid account.reporting_timezone.'
puts '2. Backfill uses this timezone and skips today by default.'
puts '3. Run backfill only after the account timezone day closes.'
puts ''
end
def prompt_account
print 'Enter Account ID: '
account_id = $stdin.gets.chomp
abort color('Error: Account ID is required', :red, :bold) if account_id.blank?
account = Account.find_by(id: account_id)
abort color("Error: Account with ID #{account_id} not found", :red, :bold) unless account
puts color("Found account: #{account.name}", :gray)
puts ''
account
end
def print_current_timezone(account)
current_timezone = account.reporting_timezone.presence || '(not set)'
puts color("Current reporting_timezone: #{current_timezone}", :gray)
puts ''
end
def prompt_timezone
loop do
print 'Enter UTC offset to pick timezone (e.g., +5:30, -8, 0): '
offset_input = $stdin.gets.chomp
abort color('Error: UTC offset is required', :red, :bold) if offset_input.blank?
matching_zones = find_matching_zones(offset_input)
abort color("Error: No timezones found for offset '#{offset_input}'", :red, :bold) if matching_zones.empty?
display_matching_zones(matching_zones, offset_input)
timezone = select_timezone(matching_zones)
return timezone if timezone.present?
end
end
def find_matching_zones(offset_input)
normalized = offset_input.gsub(/^(?!\+|-)/, '+')
parts = normalized.split(':')
hours = parts[0].to_i
minutes = (parts[1] || '0').to_i
total_seconds = (hours * 3600) + (hours.negative? ? -minutes * 60 : minutes * 60)
ActiveSupport::TimeZone.all.select { |tz| tz.utc_offset == total_seconds }
end
def display_matching_zones(zones, offset_input)
puts ''
puts color("Timezones matching UTC#{offset_input}:", :yellow, :bold)
puts ''
zones.each_with_index do |tz, index|
puts " #{index + 1}. #{tz.name} (#{tz.tzinfo.identifier})"
end
puts ' 0. Re-enter UTC offset'
puts ''
end
def select_timezone(zones)
print "Select timezone (1-#{zones.size}, 0 to go back): "
selection = $stdin.gets.chomp.to_i
return if selection.zero?
abort color('Error: Invalid selection', :red, :bold) if selection < 1 || selection > zones.size
timezone = zones[selection - 1].tzinfo.identifier
puts color("Selected timezone: #{timezone}", :gray)
puts ''
timezone
end
def confirm_and_update(account, timezone)
print "Update account #{account.id} reporting_timezone to '#{timezone}'? (y/N): "
confirm = $stdin.gets.chomp.downcase
abort 'Timezone setup cancelled' unless %w[y yes].include?(confirm)
account.update!(reporting_timezone: timezone)
puts ''
puts color("Updated reporting_timezone for account '#{account.name}' to '#{timezone}'", :green, :bold)
puts ''
end
def print_next_steps(account, timezone)
run_times = recommended_run_times(timezone)
print_next_steps_header
print_next_steps_schedule(timezone, run_times)
print_next_steps_backfill(account)
puts color('=' * 70, :green)
end
def print_next_steps_header
puts color('=' * 70, :green)
puts color('Next Steps', :bold, :green)
puts color('=' * 70, :green)
end
def print_next_steps_schedule(timezone, run_times)
puts "1. Wait for today's day-boundary to pass in #{timezone}."
puts '2. Recommended earliest backfill start time:'
puts " - #{timezone}: #{format_time(run_times[:account_tz])}"
puts " - UTC: #{format_time(run_times[:utc])}"
puts " - IST: #{format_time(run_times[:ist])}"
puts " - PCT/PT: #{format_time(run_times[:pct])}"
end
def print_next_steps_backfill(account)
puts '3. Run backfill:'
puts ' bundle exec rake reporting_events_rollup:backfill'
puts "4. Backfill will use account.reporting_timezone and skip today by default for account #{account.id}."
end
def recommended_run_times(timezone)
account_zone = ActiveSupport::TimeZone[timezone]
next_day = Time.current.in_time_zone(account_zone).to_date + 1.day
account_time = account_zone.parse(next_day.to_s) + 30.minutes
{
account_tz: account_time,
utc: account_time.in_time_zone('UTC'),
ist: account_time.in_time_zone('Asia/Kolkata'),
pct: account_time.in_time_zone('Pacific Time (US & Canada)')
}
end
def format_time(time)
time.strftime('%Y-%m-%d %H:%M:%S %Z')
end
ANSI_COLORS = {
reset: "\e[0m",
bold: "\e[1m",
red: "\e[31m",
green: "\e[32m",
yellow: "\e[33m",
cyan: "\e[36m",
gray: "\e[90m"
}.freeze
def color(text, *styles)
return text unless $stdout.tty?
codes = styles.filter_map { |style| ANSI_COLORS[style] }.join
"#{codes}#{text}#{ANSI_COLORS[:reset]}"
end
end
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.11.1",
"version": "4.11.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -34,7 +34,7 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.6",
"@chatwoot/prosemirror-schema": "1.3.7",
"@chatwoot/utils": "^0.0.52",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
+5 -5
View File
@@ -23,8 +23,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.3.6
version: 1.3.6
specifier: 1.3.7
version: 1.3.7
'@chatwoot/utils':
specifier: ^0.0.52
version: 0.0.52
@@ -454,8 +454,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.3.6':
resolution: {integrity: sha512-sHRtWqbtiow9mVF1ixim0eGUXfhGK5tuLOdF9Vf53aepjJ+ngEiNVkxQT6FohlEOd886ZsdQxMvmI92IDaUXAQ==}
'@chatwoot/prosemirror-schema@1.3.7':
resolution: {integrity: sha512-N+Gicecp18TSEJQoRtGZXkp8R+kC0iPSms8ezu1k8U+ySY9FAENzFQQ1rBVSSC4hDFwb9/EbSI9IFqDjHGds7g==}
'@chatwoot/utils@0.0.52':
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
@@ -4999,7 +4999,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.3.6':
'@chatwoot/prosemirror-schema@1.3.7':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@@ -0,0 +1,327 @@
require 'rails_helper'
describe V2::Reports::Concerns::RollupConditions do
let(:dummy_class) do
Class.new do
include V2::Reports::Concerns::RollupConditions
attr_accessor :account, :params
# Make private methods public for testing
public :metric_to_rollup_metric, :dimension_type_to_rollup, :timezone_matches_account?
end
end
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:builder) { dummy_class.new }
# Compute the current offset dynamically so tests pass in both EST and EDT
let(:current_ny_offset) { ActiveSupport::TimeZone['America/New_York'].now.utc_offset / 3600.0 }
before do
builder.account = account
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
end
describe '#use_rollup?' do
let(:valid_params) do
{
metric: 'avg_resolution_time',
type: 'account',
group_by: 'day',
timezone_offset: current_ny_offset
}
end
context 'when all conditions pass' do
it 'returns true' do
builder.params = valid_params
expect(builder.use_rollup?).to be true
end
end
context 'when reporting_timezone is blank' do
it 'returns false' do
account.update!(reporting_timezone: nil)
builder.params = valid_params
expect(builder.use_rollup?).to be false
end
end
context 'when feature flag is disabled' do
it 'returns false' do
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(false)
builder.params = valid_params
expect(builder.use_rollup?).to be false
end
end
context 'when metric is not covered' do
it 'returns false for conversations_count' do
builder.params = valid_params.merge(metric: 'conversations_count')
expect(builder.use_rollup?).to be false
end
it 'returns false for incoming_messages_count' do
builder.params = valid_params.merge(metric: 'incoming_messages_count')
expect(builder.use_rollup?).to be false
end
it 'returns false for outgoing_messages_count' do
builder.params = valid_params.merge(metric: 'outgoing_messages_count')
expect(builder.use_rollup?).to be false
end
end
context 'when metric is covered' do
it 'returns true for avg_first_response_time' do
builder.params = valid_params.merge(metric: 'avg_first_response_time')
expect(builder.use_rollup?).to be true
end
it 'returns true for avg_resolution_time' do
builder.params = valid_params
expect(builder.use_rollup?).to be true
end
it 'returns true for reply_time' do
builder.params = valid_params.merge(metric: 'reply_time')
expect(builder.use_rollup?).to be true
end
it 'returns true for resolutions_count' do
builder.params = valid_params.merge(metric: 'resolutions_count')
expect(builder.use_rollup?).to be true
end
it 'returns true for bot_resolutions_count' do
builder.params = valid_params.merge(metric: 'bot_resolutions_count')
expect(builder.use_rollup?).to be true
end
it 'returns true for bot_handoffs_count' do
builder.params = valid_params.merge(metric: 'bot_handoffs_count')
expect(builder.use_rollup?).to be true
end
end
context 'when group_by is hourly' do
it 'returns false when group_by is hour' do
builder.params = valid_params.merge(group_by: 'hour')
expect(builder.use_rollup?).to be false
end
it 'returns true for day granularity' do
builder.params = valid_params
expect(builder.use_rollup?).to be true
end
it 'returns true for week granularity' do
builder.params = valid_params.merge(group_by: 'week')
expect(builder.use_rollup?).to be true
end
it 'returns true for month granularity' do
builder.params = valid_params.merge(group_by: 'month')
expect(builder.use_rollup?).to be true
end
it 'returns true for year granularity' do
builder.params = valid_params.merge(group_by: 'year')
expect(builder.use_rollup?).to be true
end
end
context 'when dimension is not supported' do
it 'returns false for label dimension' do
builder.params = valid_params.merge(type: 'label')
expect(builder.use_rollup?).to be false
end
it 'returns true for account dimension' do
builder.params = valid_params.merge(type: 'account')
expect(builder.use_rollup?).to be true
end
it 'returns true for agent dimension' do
builder.params = valid_params.merge(type: 'agent')
expect(builder.use_rollup?).to be true
end
it 'returns true for inbox dimension' do
builder.params = valid_params.merge(type: 'inbox')
expect(builder.use_rollup?).to be true
end
it 'returns false for team dimension' do
builder.params = valid_params.merge(type: 'team')
expect(builder.use_rollup?).to be false
end
end
context 'when timezone_offset does not match' do
it 'returns false when timezone_offset is UTC' do
builder.params = valid_params.merge(timezone_offset: 0)
expect(builder.use_rollup?).to be false
end
it 'returns false when timezone_offset is UTC+5:30' do
builder.params = valid_params.merge(timezone_offset: 5.5)
expect(builder.use_rollup?).to be false
end
it 'returns true when timezone_offset matches account timezone' do
builder.params = valid_params.merge(timezone_offset: current_ny_offset)
expect(builder.use_rollup?).to be true
end
it 'returns false when timezone_offset is blank' do
builder.params = valid_params.merge(timezone_offset: nil)
expect(builder.use_rollup?).to be false
end
end
context 'when used from BaseSummaryBuilder (no params[:metric])' do
it 'returns true because summary builders override metric_covered?' do
builder.params = { type: 'agent', timezone_offset: current_ny_offset }
# Without the override, this would return false since params[:metric] is blank
expect(builder.use_rollup?).to be false
# BaseSummaryBuilder overrides metric_covered? to always return true
summary_builder_class = Class.new(dummy_class) do
def metric_covered?
true
end
end
summary_builder = summary_builder_class.new
summary_builder.account = account
summary_builder.params = { type: 'agent', timezone_offset: current_ny_offset, group_by: 'day' }
expect(summary_builder.use_rollup?).to be true
end
end
end
describe '#metric_to_rollup_metric' do
it 'maps avg_resolution_time to resolution_time' do
builder.params = { metric: 'avg_resolution_time' }
expect(builder.metric_to_rollup_metric('avg_resolution_time')).to eq(:resolution_time)
end
it 'maps avg_first_response_time to first_response' do
builder.params = { metric: 'avg_first_response_time' }
expect(builder.metric_to_rollup_metric('avg_first_response_time')).to eq(:first_response)
end
it 'maps reply_time to reply_time' do
builder.params = { metric: 'reply_time' }
expect(builder.metric_to_rollup_metric('reply_time')).to eq(:reply_time)
end
it 'maps resolutions_count to resolutions_count' do
builder.params = { metric: 'resolutions_count' }
expect(builder.metric_to_rollup_metric('resolutions_count')).to eq(:resolutions_count)
end
it 'maps bot_resolutions_count to bot_resolutions_count' do
builder.params = { metric: 'bot_resolutions_count' }
expect(builder.metric_to_rollup_metric('bot_resolutions_count')).to eq(:bot_resolutions_count)
end
it 'maps bot_handoffs_count to bot_handoffs_count' do
builder.params = { metric: 'bot_handoffs_count' }
expect(builder.metric_to_rollup_metric('bot_handoffs_count')).to eq(:bot_handoffs_count)
end
end
describe '#dimension_type_to_rollup' do
it 'maps account type to account' do
builder.params = { type: 'account' }
expect(builder.dimension_type_to_rollup).to eq(:account)
end
it 'maps agent type to agent' do
builder.params = { type: 'agent' }
expect(builder.dimension_type_to_rollup).to eq(:agent)
end
it 'maps inbox type to inbox' do
builder.params = { type: 'inbox' }
expect(builder.dimension_type_to_rollup).to eq(:inbox)
end
it 'returns nil for team type' do
builder.params = { type: 'team' }
expect(builder.dimension_type_to_rollup).to be_nil
end
it 'returns nil for unsupported type' do
builder.params = { type: 'label' }
expect(builder.dimension_type_to_rollup).to be_nil
end
it 'returns nil when type is blank' do
builder.params = { type: nil }
expect(builder.dimension_type_to_rollup).to be_nil
end
end
describe '#timezone_matches_account?' do
it 'returns false when timezone_offset is blank' do
builder.params = { timezone_offset: nil }
expect(builder.timezone_matches_account?).to be false
end
it 'returns true when offset matches account timezone current offset' do
builder.params = { timezone_offset: current_ny_offset }
expect(builder.timezone_matches_account?).to be true
end
it 'returns false when offset does not match account timezone' do
# UTC+5:30 (IST) will never match America/New_York
builder.params = { timezone_offset: 5.5 }
expect(builder.timezone_matches_account?).to be false
end
it 'returns false when account timezone is not recognized' do
allow(account).to receive(:reporting_timezone).and_return('Invalid/Zone')
builder.params = { timezone_offset: current_ny_offset }
expect(builder.timezone_matches_account?).to be false
end
it 'works with fractional offsets like UTC+5:45' do
account.update!(reporting_timezone: 'Asia/Kathmandu')
kathmandu_offset = ActiveSupport::TimeZone['Asia/Kathmandu'].now.utc_offset / 3600.0
builder.params = { timezone_offset: kathmandu_offset }
expect(builder.timezone_matches_account?).to be true
end
it 'handles DST-aware comparison correctly' do
# Use a timezone with no DST (UTC) to have a stable control
account.update!(reporting_timezone: 'Etc/UTC')
builder.params = { timezone_offset: 0 }
expect(builder.timezone_matches_account?).to be true
# A non-zero offset should not match UTC
builder.params = { timezone_offset: -5 }
expect(builder.timezone_matches_account?).to be false
end
end
end
@@ -79,6 +79,59 @@ describe V2::Reports::Timeseries::AverageReportBuilder do
end
end
context 'when rollups are enabled' do
let(:timezone_offset) { '5.5' }
before do
account.update!(reporting_timezone: 'Chennai')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
create(:reporting_events_rollup,
account: account,
date: (current_time - 1.week).to_date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 1,
sum_value: 93.0,
sum_value_business_hours: 30.0)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 2,
sum_value: 180.0,
sum_value_business_hours: 30.0)
end
it 'preserves empty buckets in the timeseries' do
expected_timeseries = subject.send(:rollup_date_range).map do |date|
value = if date == (current_time - 1.week).to_date
93.0
elsif date == current_time.to_date
90.0
else
0
end
count = if date == (current_time - 1.week).to_date
1
elsif date == current_time.to_date
2
else
0
end
{ count: count, timestamp: date.in_time_zone('Chennai').to_i, value: value }
end
expect(subject.timeseries).to eq(expected_timeseries)
end
end
context 'when group_by is provided' do
let(:group_by) { 'week' }
@@ -107,6 +160,45 @@ describe V2::Reports::Timeseries::AverageReportBuilder do
)
end
end
context 'when weekly rollups are enabled' do
let(:group_by) { 'week' }
let(:timezone_offset) { '5.5' }
before do
account.update!(reporting_timezone: 'Chennai')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date - 1.day,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 1,
sum_value: 80.0,
sum_value_business_hours: 10.0)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response',
count: 1,
sum_value: 100.0,
sum_value_business_hours: 20.0)
end
it 'groups weeks using sunday boundaries' do
expect(subject.timeseries).to eq(
[
{ count: 0, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 0 },
{ count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
]
)
end
end
end
context 'when the label filter is applied' do
@@ -170,5 +262,20 @@ describe V2::Reports::Timeseries::AverageReportBuilder do
expect(subject.aggregate_value).to eq 91.0
end
end
context 'when rollups are enabled and the agent does not exist' do
let(:filter_type) { :agent }
let(:filter_id) { '999999' }
let(:timezone_offset) { '0' }
before do
account.update!(reporting_timezone: 'Etc/UTC')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
end
it 'raises record not found to preserve raw path behavior' do
expect { subject.aggregate_value }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end
@@ -14,11 +14,16 @@ describe V2::Reports::Timeseries::CountReportBuilder do
{
type: 'agent',
metric: 'resolutions_count',
since: (current_time - 1.day).beginning_of_day.to_i.to_s,
since: since_time.beginning_of_day.to_i.to_s,
until: current_time.end_of_day.to_i.to_s,
timezone_offset: timezone_offset,
group_by: group_by,
id: user.id.to_s
}
end
let(:group_by) { 'day' }
let(:since_time) { current_time - 1.day }
let(:timezone_offset) { nil }
before do
travel_to current_time
@@ -81,6 +86,23 @@ describe V2::Reports::Timeseries::CountReportBuilder do
expect(subject.aggregate_value).to eq(2)
end
context 'when rollups are enabled and the agent does not exist' do
let(:timezone_offset) { '0' }
let(:params) do
super().merge(id: '999999')
end
before do
account.update!(reporting_timezone: 'Etc/UTC')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
end
it 'raises record not found to preserve raw path behavior' do
expect { subject.aggregate_value }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when querying account2' do
subject { described_class.new(account2, params) }
@@ -98,6 +120,47 @@ describe V2::Reports::Timeseries::CountReportBuilder do
total_count = result.sum { |r| r[:value] }
expect(total_count).to eq(2)
end
context 'when rollups are enabled and grouped by week' do
let(:group_by) { 'week' }
let(:current_time) { Time.zone.parse('2020-10-26 10:00:00 UTC') }
let(:since_time) { current_time - 1.week }
let(:timezone_offset) { '5.5' }
before do
account.update!(reporting_timezone: 'Chennai')
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date - 1.day,
dimension_type: 'agent',
dimension_id: user.id,
metric: 'resolutions_count',
count: 1,
sum_value: 0.0,
sum_value_business_hours: 0.0)
create(:reporting_events_rollup,
account: account,
date: current_time.to_date,
dimension_type: 'agent',
dimension_id: user.id,
metric: 'resolutions_count',
count: 1,
sum_value: 0.0,
sum_value_business_hours: 0.0)
end
it 'groups weeks using sunday boundaries' do
expect(subject.timeseries).to eq(
[
{ value: 0, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i },
{ value: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i }
]
)
end
end
end
describe 'account isolation' do
@@ -45,7 +45,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(V2::Reports::AgentSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :agent)
)
expect(agent_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -96,7 +99,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(V2::Reports::InboxSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :inbox)
)
expect(inbox_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -147,7 +153,10 @@ RSpec.describe 'Summary Reports API', type: :request do
headers: admin.create_new_auth_token,
as: :json
expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(account: account, params: params)
expect(V2::Reports::TeamSummaryBuilder).to have_received(:new).with(
account: account,
params: params.merge(type: :team)
)
expect(team_summary_builder).to have_received(:build)
expect(response).to have_http_status(:success)
@@ -12,7 +12,7 @@ RSpec.describe 'Super Admin Users API', type: :request do
end
context 'when it is an authenticated super admin' do
let!(:user) { create(:user) }
let!(:user) { create(:user, name: 'Disabled User') }
let!(:params) do
{ user: {
name: 'admin@example.com',
@@ -23,13 +23,46 @@ RSpec.describe 'Super Admin Users API', type: :request do
type: 'SuperAdmin'
} }
end
let!(:params_without_confirmed_at) do
{ user: {
name: 'agent@example.com',
display_name: 'agent@example.com',
email: 'agent@example.com',
password: 'Password1!',
type: 'SuperAdmin'
} }
end
let!(:params_with_blank_confirmed_at) do
{ user: {
name: 'agent-2@example.com',
display_name: 'agent-2@example.com',
email: 'agent-2@example.com',
password: 'Password1!',
confirmed_at: '',
type: 'SuperAdmin'
} }
end
it 'shows the list of users' do
sign_in(super_admin, scope: :super_admin)
get '/super_admin/users'
doc = Nokogiri::HTML(response.body)
header_texts = doc.css('table thead th').map { |header| header.text.squish }
expect(response).to have_http_status(:success)
expect(response.body).to include('New user')
expect(response.body).to include(CGI.escapeHTML(user.name))
expect(header_texts).not_to include('MFA')
end
it 'prefills confirmed_at on new user form' do
sign_in(super_admin, scope: :super_admin)
get '/super_admin/users/new'
expect(response).to have_http_status(:success)
expect(response.body).to include('name="user[confirmed_at]"')
confirmed_at_value = response.body[/name="user\[confirmed_at\]".*?value="([^"]+)"/m, 1]
expect(confirmed_at_value).to be_present
end
it 'creates the new super_admin record' do
@@ -43,6 +76,24 @@ RSpec.describe 'Super Admin Users API', type: :request do
post '/super_admin/users', params: params
expect(response).to redirect_to('http://www.example.com/super_admin/users/new')
end
it 'creates unconfirmed users when confirmed_at is not provided in payload' do
sign_in(super_admin, scope: :super_admin)
post '/super_admin/users', params: params_without_confirmed_at
expect(response).to redirect_to("http://www.example.com/super_admin/users/#{User.last.id}")
expect(User.last).not_to be_confirmed
end
it 'creates unconfirmed users when confirmed_at is explicitly cleared' do
sign_in(super_admin, scope: :super_admin)
post '/super_admin/users', params: params_with_blank_confirmed_at
expect(response).to redirect_to("http://www.example.com/super_admin/users/#{User.last.id}")
expect(User.last).not_to be_confirmed
end
end
end
@@ -100,4 +151,21 @@ RSpec.describe 'Super Admin Users API', type: :request do
expect(mail_jobs.count).to be >= 1
end
end
describe 'GET /super_admin/users/:id' do
let!(:user) { create(:user, name: 'MFA Enabled User', otp_required_for_login: true) }
it 'shows the MFA status on the user detail page' do
sign_in(super_admin, scope: :super_admin)
get "/super_admin/users/#{user.id}"
doc = Nokogiri::HTML(response.body)
labels = doc.css('dt.attribute-label').map { |label| label.text.squish }
expect(response).to have_http_status(:success)
expect(labels).to include('MFA')
expect(response.body).to include('Enabled')
expect(response.body).to include(CGI.escapeHTML(user.name))
end
end
end
+12
View File
@@ -0,0 +1,12 @@
FactoryBot.define do
factory :reporting_events_rollup do
account
date { Date.current }
dimension_type { 'account' }
dimension_id { 1 }
metric { 'first_response' }
count { 1 }
sum_value { 100.0 }
sum_value_business_hours { 50.0 }
end
end
+1 -1
View File
@@ -42,7 +42,7 @@ describe OnlineStatusTracker do
described_class.update_presence(account.id, 'Contact', online_contact.id)
# creating a stale record for offline contact presence
Redis::Alfred.zadd(format(Redis::Alfred::ONLINE_PRESENCE_CONTACTS, account_id: account.id),
(Time.zone.now - (OnlineStatusTracker::PRESENCE_DURATION + 20)).to_i, offline_contact.id)
(Time.zone.now - (OnlineStatusTracker::CONTACT_PRESENCE_DURATION + 20)).to_i, offline_contact.id)
end
it 'returns only the online contact ids with presence' do
+15
View File
@@ -217,6 +217,21 @@ RSpec.describe Account do
expect(described_class.with_auto_resolve.pluck(:id)).not_to include(account.id)
end
end
context 'when reporting_timezone is set' do
it 'allows valid timezone names' do
account.reporting_timezone = 'America/New_York'
expect(account).to be_valid
end
it 'rejects invalid timezone names' do
account.reporting_timezone = 'Invalid/Timezone'
expect(account).not_to be_valid
expect(account.errors[:reporting_timezone]).to include(I18n.t('errors.account.reporting_timezone.invalid'))
end
end
end
describe 'captain_preferences' do
@@ -0,0 +1,61 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe CustomAttributeDefinition do
let(:account) { create(:account) }
describe 'validations' do
describe 'attribute_key format' do
it 'allows alphanumeric keys with underscores' do
cad = build(:custom_attribute_definition, account: account, attribute_key: 'order_date_1')
expect(cad).to be_valid
end
it 'allows hyphens and dots' do
cad = build(:custom_attribute_definition, account: account, attribute_key: 'order-date.v2')
expect(cad).to be_valid
end
it 'allows Unicode letters' do
cad = build(:custom_attribute_definition, account: account, attribute_key: '客户类型')
expect(cad).to be_valid
end
it 'rejects keys with single quotes' do
cad = build(:custom_attribute_definition, account: account, attribute_key: "x'||(SELECT 1)||'")
expect(cad).not_to be_valid
expect(cad.errors[:attribute_key]).to be_present
end
it 'rejects keys with spaces' do
cad = build(:custom_attribute_definition, account: account, attribute_key: 'order date')
expect(cad).not_to be_valid
end
it 'rejects keys with semicolons' do
cad = build(:custom_attribute_definition, account: account, attribute_key: 'key; DROP TABLE users--')
expect(cad).not_to be_valid
end
it 'rejects keys with parentheses' do
cad = build(:custom_attribute_definition, account: account, attribute_key: 'key()')
expect(cad).not_to be_valid
end
end
end
describe 'callbacks' do
describe '#strip_attribute_key' do
it 'strips leading and trailing whitespace from attribute_key' do
cad = create(:custom_attribute_definition, account: account, attribute_key: ' order_date ')
expect(cad.attribute_key).to eq('order_date')
end
it 'strips leading and trailing whitespace from attribute_display_name' do
cad = create(:custom_attribute_definition, account: account, attribute_display_name: ' Order Date ')
expect(cad.attribute_display_name).to eq('Order Date')
end
end
end
end
+182
View File
@@ -0,0 +1,182 @@
require 'rails_helper'
RSpec.describe ReportingEventsRollup do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'enums' do
describe 'dimension_type enum' do
it 'stores dimension_type as string values' do
rollup = build(:reporting_events_rollup, dimension_type: 'account')
expect(rollup.dimension_type).to eq('account')
end
it 'has account dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'account')
expect(rollup.account?).to be true
end
it 'has agent dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'agent')
expect(rollup.agent?).to be true
end
it 'has inbox dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'inbox')
expect(rollup.inbox?).to be true
end
it 'has team dimension type' do
rollup = build(:reporting_events_rollup, dimension_type: 'team')
expect(rollup.team?).to be true
end
end
describe 'metric enum' do
it 'stores metric as string values' do
rollup = build(:reporting_events_rollup, metric: 'first_response')
expect(rollup.metric).to eq('first_response')
end
it 'has resolutions_count metric' do
rollup = build(:reporting_events_rollup, metric: 'resolutions_count')
expect(rollup.resolutions_count?).to be true
end
it 'has first_response metric' do
rollup = build(:reporting_events_rollup, metric: 'first_response')
expect(rollup.first_response?).to be true
end
it 'has resolution_time metric' do
rollup = build(:reporting_events_rollup, metric: 'resolution_time')
expect(rollup.resolution_time?).to be true
end
it 'has reply_time metric' do
rollup = build(:reporting_events_rollup, metric: 'reply_time')
expect(rollup.reply_time?).to be true
end
it 'has bot_resolutions_count metric' do
rollup = build(:reporting_events_rollup, metric: 'bot_resolutions_count')
expect(rollup.bot_resolutions_count?).to be true
end
it 'has bot_handoffs_count metric' do
rollup = build(:reporting_events_rollup, metric: 'bot_handoffs_count')
expect(rollup.bot_handoffs_count?).to be true
end
end
end
describe 'validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:date) }
it { is_expected.to validate_presence_of(:dimension_type) }
it { is_expected.to validate_presence_of(:dimension_id) }
it { is_expected.to validate_presence_of(:metric) }
it { is_expected.to validate_numericality_of(:count).is_greater_than_or_equal_to(0) }
end
describe 'scopes' do
let(:account) { create(:account) }
let(:other_account) { create(:account) }
describe '.for_date_range' do
it 'filters by date range' do
create(:reporting_events_rollup, account: account, date: '2026-02-08'.to_date)
create(:reporting_events_rollup, account: account, date: '2026-02-10'.to_date)
create(:reporting_events_rollup, account: account, date: '2026-02-12'.to_date)
results = described_class.for_date_range('2026-02-10'.to_date, '2026-02-11'.to_date)
expect(results.count).to eq(1)
expect(results.first.date).to eq('2026-02-10'.to_date)
end
it 'returns empty array when no records match' do
create(:reporting_events_rollup, account: account, date: '2026-02-08'.to_date)
results = described_class.for_date_range('2026-02-20'.to_date, '2026-02-25'.to_date)
expect(results).to be_empty
end
end
describe '.for_dimension' do
it 'filters by dimension type and id' do
create(:reporting_events_rollup, account: account, dimension_type: 'account', dimension_id: 1)
create(:reporting_events_rollup, account: account, dimension_type: 'agent', dimension_id: 1)
create(:reporting_events_rollup, account: account, dimension_type: 'agent', dimension_id: 2)
results = described_class.for_dimension('agent', 1)
expect(results.count).to eq(1)
expect(results.first.dimension_type).to eq('agent')
expect(results.first.dimension_id).to eq(1)
end
it 'returns empty array when no records match' do
create(:reporting_events_rollup, account: account, dimension_type: 'account', dimension_id: 1)
results = described_class.for_dimension('agent', 1)
expect(results).to be_empty
end
end
describe '.for_metric' do
it 'filters by metric' do
create(:reporting_events_rollup, account: account, metric: 'first_response', dimension_id: 1)
create(:reporting_events_rollup, account: account, metric: 'resolution_time', dimension_id: 2)
create(:reporting_events_rollup, account: account, metric: 'first_response', dimension_id: 3)
results = described_class.for_metric('first_response')
expect(results.count).to eq(2)
expect(results.all? { |r| r.metric == 'first_response' }).to be true
end
it 'returns empty array when no records match' do
create(:reporting_events_rollup, account: account, metric: 'first_response')
results = described_class.for_metric('resolution_time')
expect(results).to be_empty
end
end
end
describe 'database schema' do
let(:account) { create(:account) }
let(:rollup) do
create(:reporting_events_rollup,
account: account,
date: '2026-02-10'.to_date,
dimension_type: 'account',
metric: 'first_response')
end
it 'has all required columns' do
expect(rollup).to have_attributes(
account_id: account.id,
date: '2026-02-10'.to_date,
dimension_type: 'account',
dimension_id: 1,
metric: 'first_response',
count: 1,
sum_value: 100.0,
sum_value_business_hours: 50.0
)
end
it 'stores enum values as strings in database' do
rollup
db_record = described_class.find(rollup.id)
expect(db_record.dimension_type).to eq('account')
expect(db_record.metric).to eq('first_response')
end
end
end
+74
View File
@@ -0,0 +1,74 @@
require 'rails_helper'
RSpec.describe 'Signup enforcement', type: :request do
let(:signup_config_name) { 'ENABLE_ACCOUNT_SIGNUP' }
before do
GlobalConfig.clear_cache
InstallationConfig.where(name: signup_config_name).delete_all
InstallationConfig.create!(name: signup_config_name, value: false, locked: false)
end
after do
InstallationConfig.where(name: signup_config_name).delete_all
GlobalConfig.clear_cache
OmniAuth.config.mock_auth[:google_oauth2] = nil
end
describe 'POST /api/v1/accounts' do
it 'blocks signup when the config is stored as boolean false' do
post api_v1_accounts_url,
params: {
account_name: 'verify',
user_full_name: 'Verify User',
email: "verify-#{SecureRandom.hex(4)}@example.com",
password: 'Password1!'
},
as: :json
expect(response).to have_http_status(:not_found)
end
end
describe 'POST /api/v2/accounts' do
it 'blocks signup when the config is stored as boolean false' do
post api_v2_accounts_url,
params: {
email: "verify-#{SecureRandom.hex(4)}@example.com",
password: 'Password1!'
},
as: :json
expect(response).to have_http_status(:not_found)
end
end
describe 'GET /omniauth/google_oauth2/callback' do
let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService, perform: true) }
before do
OmniAuth.config.test_mode = true
allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
end
it 'redirects to no-account-found when the config is stored as boolean false' do
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
provider: 'google',
uid: '123545',
info: {
name: 'test',
email: "verify-#{SecureRandom.hex(4)}@example.com",
image: 'https://example.com/image.jpg'
}
)
with_modified_env FRONTEND_URL: 'http://www.example.com' do
get '/omniauth/google_oauth2/callback'
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?error=no-account-found$})
end
end
end
end
+105 -1
View File
@@ -49,6 +49,11 @@ describe Contacts::FilterService do
account: account,
attribute_model: 'contact_attribute',
attribute_display_type: 'date')
create(:custom_attribute_definition,
attribute_key: 'lifetime_value',
account: account,
attribute_model: 'contact_attribute',
attribute_display_type: 'number')
end
describe '#perform' do
@@ -60,7 +65,7 @@ describe Contacts::FilterService do
en_contact.update!(custom_attributes: { contact_additional_information: 'test custom data' })
el_contact.update!(custom_attributes: { contact_additional_information: 'test custom data', customer_type: 'platinum' })
cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19' })
cs_contact.update!(custom_attributes: { customer_type: 'platinum', signed_in_at: '2022-01-19', lifetime_value: '120.50' })
end
context 'with standard attributes - name' do
@@ -272,6 +277,39 @@ describe Contacts::FilterService do
expect(result[:contacts].pluck(:id)).to include(cs_contact.id)
expect(result[:contacts].pluck(:id)).not_to include(en_contact.id)
end
it 'binds last_activity_at comparison values as dates' do
date_value = '2024-01-01'
params[:payload] = [
{
attribute_key: 'last_activity_at',
filter_operator: 'is_greater_than',
values: [date_value],
query_operator: nil
}.with_indifferent_access
]
service = filter_service.new(account, first_user, params)
filters = service.instance_variable_get(:@filters)['contacts']
condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
expect(condition_query).to include('(contacts.last_activity_at)::date > :value_0')
expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
end
it 'rejects invalid last_activity_at comparison values' do
malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
params[:payload] = [
{
attribute_key: 'last_activity_at',
filter_operator: 'is_greater_than',
values: [malicious_value],
query_operator: nil
}.with_indifferent_access
]
expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
end
end
context 'with additional attributes' do
@@ -369,6 +407,72 @@ describe Contacts::FilterService do
expect(result[:contacts].length).to be expected_count
expect(result[:contacts].pluck(:id)).to include(el_contact.id)
end
it 'binds custom date comparison values as dates' do
date_value = '2024-01-01'
params[:payload] = [
{
attribute_key: 'signed_in_at',
filter_operator: 'is_less_than',
values: [date_value],
query_operator: nil
}.with_indifferent_access
]
service = filter_service.new(account, first_user, params)
filters = service.instance_variable_get(:@filters)['contacts']
condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
expect(condition_query).to include("(contacts.custom_attributes ->> 'signed_in_at')::date < :value_0")
expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
end
it 'binds custom numeric comparison values as decimals' do
params[:payload] = [
{
attribute_key: 'lifetime_value',
filter_operator: 'is_greater_than',
values: ['100.25'],
query_operator: nil
}.with_indifferent_access
]
service = filter_service.new(account, first_user, params)
filters = service.instance_variable_get(:@filters)['contacts']
condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
expect(condition_query).to include("(contacts.custom_attributes ->> 'lifetime_value')::numeric > :value_0")
expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(BigDecimal('100.25'))
end
it 'filters by custom numeric attributes' do
params[:payload] = [
{
attribute_key: 'lifetime_value',
filter_operator: 'is_greater_than',
values: ['100.25'],
query_operator: nil
}.with_indifferent_access
]
result = filter_service.new(account, first_user, params).perform
expect(result[:contacts].pluck(:id)).to eq([cs_contact.id])
end
it 'rejects invalid custom date comparison values' do
malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
params[:payload] = [
{
attribute_key: 'signed_in_at',
filter_operator: 'is_less_than',
values: [malicious_value],
query_operator: nil
}.with_indifferent_access
]
expect { filter_service.new(account, first_user, params).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
end
end
end
end
@@ -417,6 +417,41 @@ describe Conversations::FilterService do
expect(result[:conversations].length).to be expected_count
end
it 'binds created_at comparison values as dates' do
date_value = '2024-01-01'
params[:payload] = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: [date_value],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
service = filter_service.new(params, user_1, account)
filters = service.instance_variable_get(:@filters)['conversations']
condition_query = service.send(:build_condition_query, filters, params[:payload].first, 0)
expect(condition_query).to include('(conversations.created_at)::date > :value_0')
expect(service.instance_variable_get(:@filter_values)['value_0']).to eq(Date.iso8601(date_value))
end
it 'rejects invalid created_at comparison values' do
malicious_value = "2024-01-01'::date OR (SELECT pg_sleep(5)) IS NOT NULL --"
params[:payload] = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: [malicious_value],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access
]
expect { filter_service.new(params, user_1, account).perform }.to raise_error(CustomExceptions::CustomFilter::InvalidValue)
end
it 'filter by created_at and conversation_type' do
params[:payload] = [
{
@@ -0,0 +1,45 @@
require 'rails_helper'
describe ReportingEvents::BackfillService do
describe '.backfill_date' do
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:date) { Date.new(2026, 2, 11) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
it 'treats nil metric values as zero during backfill' do
reporting_event = create(
:reporting_event,
account: account,
name: 'first_response',
value: 100,
value_in_business_hours: 50,
user: user,
inbox: inbox,
conversation: conversation,
created_at: Time.utc(2026, 2, 11, 15)
)
# Simulate a legacy row that already exists in the database with nil metrics.
# rubocop:disable Rails/SkipsModelValidations
reporting_event.update_columns(value: nil, value_in_business_hours: nil)
# rubocop:enable Rails/SkipsModelValidations
expect do
described_class.backfill_date(account, date)
end.not_to raise_error
rollup = ReportingEventsRollup.find_by!(
account_id: account.id,
date: date,
dimension_type: 'account',
dimension_id: account.id,
metric: 'first_response'
)
expect(rollup.count).to eq(1)
expect(rollup.sum_value).to eq(0)
expect(rollup.sum_value_business_hours).to eq(0)
end
end
end
@@ -0,0 +1,397 @@
require 'rails_helper'
describe ReportingEvents::RollupService do
describe '.perform' do
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
let(:team) { create(:team, account: account) }
context 'when reporting_timezone is not set' do
before { account.update!(reporting_timezone: nil) }
it 'skips rollup creation' do
reporting_event = create(:reporting_event,
account: account,
name: 'conversation_resolved',
conversation: conversation)
expect do
described_class.perform(reporting_event)
end.not_to change(ReportingEventsRollup, :count)
end
end
context 'when reporting_timezone is invalid' do
it 'skips rollup creation' do
reporting_event = create(:reporting_event,
account: account,
name: 'conversation_resolved',
conversation: conversation)
allow(account).to receive(:reporting_timezone).and_return('Invalid/Zone')
expect do
described_class.perform(reporting_event)
end.not_to change(ReportingEventsRollup, :count)
end
end
context 'when reporting_timezone is set' do
describe 'conversation_resolved event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 1000,
value_in_business_hours: 500,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates rollup rows for all dimensions' do
described_class.perform(reporting_event)
# Account dimension
account_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
dimension_id: account.id,
metric: 'resolutions_count'
)
expect(account_row).to be_present
# Agent dimension
agent_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'agent',
dimension_id: user.id,
metric: 'resolutions_count'
)
expect(agent_row).to be_present
# Inbox dimension
inbox_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'inbox',
dimension_id: inbox.id,
metric: 'resolutions_count'
)
expect(inbox_row).to be_present
end
it 'creates correct metrics for conversation_resolved' do
described_class.perform(reporting_event)
resolutions_count = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'resolutions_count'
)
expect(resolutions_count.count).to eq(1)
expect(resolutions_count.sum_value).to eq(0)
expect(resolutions_count.sum_value_business_hours).to eq(0)
resolution_time = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'resolution_time'
)
expect(resolution_time.count).to eq(1)
expect(resolution_time.sum_value).to eq(1000)
expect(resolution_time.sum_value_business_hours).to eq(500)
end
it 'respects account timezone for date bucketing' do
# Event created at 2026-02-11 22:00 UTC
# In EST (UTC-5) that's 2026-02-11 17:00 (same day)
reporting_event.update!(created_at: '2026-02-11 22:00:00 UTC')
described_class.perform(reporting_event)
rollup = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account'
)
expect(rollup.date).to eq('2026-02-11'.to_date)
end
it 'handles timezone boundary crossing' do
# Event created at 2026-02-12 04:00 UTC
# In EST (UTC-5) that's 2026-02-11 23:00 (previous day)
reporting_event.update!(created_at: '2026-02-12 04:00:00 UTC')
described_class.perform(reporting_event)
rollup = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account'
)
expect(rollup.date).to eq('2026-02-11'.to_date)
end
end
describe 'first_response event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'first_response',
value: 500,
value_in_business_hours: 300,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates first_response metric' do
described_class.perform(reporting_event)
first_response = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'first_response'
)
expect(first_response).to be_present
expect(first_response.count).to eq(1)
expect(first_response.sum_value).to eq(500)
expect(first_response.sum_value_business_hours).to eq(300)
end
end
describe 'reply_time event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'reply_time',
value: 200,
value_in_business_hours: 100,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates reply_time metric' do
described_class.perform(reporting_event)
reply_time = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'reply_time'
)
expect(reply_time).to be_present
expect(reply_time.count).to eq(1)
expect(reply_time.sum_value).to eq(200)
expect(reply_time.sum_value_business_hours).to eq(100)
end
end
describe 'when metric values are nil' do
[
%w[conversation_resolved resolution_time],
%w[first_response first_response],
%w[reply_time reply_time]
].each do |event_name, metric|
it "treats nil values as zero for #{event_name}" do
reporting_event = create(:reporting_event,
account: account,
name: event_name,
value: 100,
value_in_business_hours: 50,
user: user,
inbox: inbox,
conversation: conversation)
reporting_event.assign_attributes(value: nil, value_in_business_hours: nil)
expect do
described_class.perform(reporting_event)
end.not_to raise_error
rollup = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: metric
)
expect(rollup).to be_present
expect(rollup.count).to eq(1)
expect(rollup.sum_value).to eq(0)
expect(rollup.sum_value_business_hours).to eq(0)
end
end
end
describe 'conversation_bot_resolved event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_bot_resolved',
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates bot_resolutions_count metric' do
described_class.perform(reporting_event)
bot_resolutions = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'bot_resolutions_count'
)
expect(bot_resolutions).to be_present
expect(bot_resolutions.count).to eq(1)
expect(bot_resolutions.sum_value).to eq(0)
end
end
describe 'conversation_bot_handoff event' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_bot_handoff',
user: user,
inbox: inbox,
conversation: conversation)
end
it 'creates bot_handoffs_count metric' do
described_class.perform(reporting_event)
bot_handoffs = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'bot_handoffs_count'
)
expect(bot_handoffs).to be_present
expect(bot_handoffs.count).to eq(1)
expect(bot_handoffs.sum_value).to eq(0)
end
end
describe 'dimension handling' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 1000,
value_in_business_hours: 500,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'skips dimensions with nil IDs' do
# Create event without user (user_id will be nil)
reporting_event.update!(user_id: nil)
described_class.perform(reporting_event)
# Agent dimension should not be created
agent_rows = ReportingEventsRollup.where(
account_id: account.id,
dimension_type: 'agent'
)
expect(agent_rows).to be_empty
end
it 'loads team_id from conversation' do
conversation.update!(team_id: team.id)
described_class.perform(reporting_event)
team_row = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'team',
dimension_id: team.id
)
expect(team_row).to be_present
end
end
describe 'upsert behavior' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 1000,
value_in_business_hours: 500,
user: user,
inbox: inbox,
conversation: conversation)
end
it 'increments count and sums on duplicate entries' do
# First call
described_class.perform(reporting_event)
resolution_time = ReportingEventsRollup.find_by(
account_id: account.id,
dimension_type: 'account',
metric: 'resolution_time'
)
expect(resolution_time.count).to eq(1)
expect(resolution_time.sum_value).to eq(1000)
expect(resolution_time.sum_value_business_hours).to eq(500)
# Second call with same event
reporting_event2 = create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 500,
value_in_business_hours: 250,
user: user,
inbox: inbox,
conversation: conversation,
created_at: reporting_event.created_at)
described_class.perform(reporting_event2)
# Total should be incremented
resolution_time.reload
expect(resolution_time.count).to eq(2)
expect(resolution_time.sum_value).to eq(1500)
expect(resolution_time.sum_value_business_hours).to eq(750)
end
it 'does not create duplicate rollup rows' do
described_class.perform(reporting_event)
initial_count = ReportingEventsRollup.count
# Create another event with same date and dimensions
reporting_event2 = create(:reporting_event,
account: account,
name: 'conversation_resolved',
value: 500,
value_in_business_hours: 250,
user: user,
inbox: inbox,
conversation: conversation,
created_at: reporting_event.created_at)
described_class.perform(reporting_event2)
# Row count should remain the same
expect(ReportingEventsRollup.count).to eq(initial_count)
end
end
end
context 'when event name in unknown' do
let(:reporting_event) do
create(:reporting_event,
account: account,
name: 'unknown_event',
user: user,
inbox: inbox,
conversation: conversation)
end
it 'does not create any rollup rows' do
expect do
described_class.perform(reporting_event)
end.not_to change(ReportingEventsRollup, :count)
end
end
end
end