Packages
phoenix_kit
1.7.104
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
221 Versions
Jump to
Current section
221 Versions
Compare versions
33
files changed
+259
additions
-6024
deletions
| @@ -1,3 +1,33 @@ | |
| 1 | + ## 1.7.104 - 2026-05-04 |
| 2 | + |
| 3 | + ### Changed |
| 4 | + - Customer Service module extracted from core into the standalone `phoenix_kit_customer_support` Hex package — companion repo: [BeamLabEU/phoenix_kit_customer_support](https://github.com/BeamLabEU/phoenix_kit_customer_support) (PR #514) |
| 5 | + - Removed `lib/modules/customer_service/` (~6 KLOC, 22 files) and `lib/phoenix_kit_web/routes/customer_service.ex`; the module is now an external optional dependency |
| 6 | + - `module_registry.ex` — dropped `PhoenixKit.Modules.CustomerService` from `internal_modules/0`, added the corresponding `phoenix_kit_customer_support` entry to `known_external_packages/0` |
| 7 | + - `integration.ex` — replaced inline `/dashboard/customer-service/tickets` route blocks with `Code.ensure_loaded?(PhoenixKitCustomerSupport.Web.UserList)` guards so absent-package = no routes |
| 8 | + - DB tables (`phoenix_kit_tickets`, `phoenix_kit_ticket_*`) stay in core under their existing names — they're domain-shaped, not module-shaped, and the prior migrations (V35/V51/V53/V58/V72/V74/V75/V77) remain in core's migration history |
| 9 | + - Renamed "Customer Service" → "Customer Support" across the public surface (PR #514) |
| 10 | + - Module: `PhoenixKitCustomerService` → `PhoenixKitCustomerSupport` |
| 11 | + - OTP app: `:phoenix_kit_customer_service` → `:phoenix_kit_customer_support` |
| 12 | + - Hex package: `phoenix_kit_customer_service` → `phoenix_kit_customer_support` |
| 13 | + - Settings keys: `customer_service_*` → `customer_support_*` (7 keys) |
| 14 | + - URL paths: `/customer-service/*` → `/customer-support/*` (admin + user-facing, both base and locale-prefixed routes) |
| 15 | + - Permission key: `customer_service` → `customer_support` |
| 16 | + - Dashboard module card and admin nav target updated to match |
| 17 | + |
| 18 | + ### Added |
| 19 | + - V109 migration: rename Customer Service module identifiers in-place so existing installs migrate cleanly (PR #514) |
| 20 | + - Renames 7 settings keys from `customer_service_*` → `customer_support_*` in `phoenix_kit_settings` |
| 21 | + - Renames `auto_granted_perm:customer_service` → `auto_granted_perm:customer_support` |
| 22 | + - Renames `phoenix_kit_role_permissions.module_key` from `customer_service` → `customer_support` |
| 23 | + - Idempotent (`IF EXISTS` guards on every rename); reversible `down/1` for emergency rollback |
| 24 | + - `@current_version` 108 → 109; ⚡ LATEST tag moved off V107 onto V109 |
| 25 | + |
| 26 | + ### Fixed |
| 27 | + - `PhoenixKit.Users.Auth.anonymize_user_tickets/1` was a no-op since the original Tickets → CustomerService rename — `Module.concat([PhoenixKit, Modules, Tickets, Ticket])` resolved to a never-loaded module so the `Code.ensure_loaded?` guard always failed and ticket anonymization silently skipped on user deletion. Now points at `PhoenixKitCustomerSupport.Ticket` (PR #514) |
| 28 | + - V108 (drag-and-drop position columns, shipped in 1.7.103) was missing from the `lib/phoenix_kit/migrations/postgres.ex` per-version docstring catalog. Backfilled in this release alongside the V109 entry (PR #514 review) |
| 29 | + - `lib/phoenix_kit/migrations/postgres/v109.ex` `rename_role_permission/4` carried an unused `_prefix` arg — the table name is already prefix-qualified at the call site. Trimmed to `/3` (PR #514 review) |
| 30 | + |
| 1 31 | ## 1.7.103 - 2026-05-02 |
| 2 32 | |
| 3 33 | ### Added |
| @@ -1,6 +1,6 @@ | |
| 1 1 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/BeamLabEU/phoenix_kit">>}]}. |
| 2 2 | {<<"name">>,<<"phoenix_kit">>}. |
| 3 | - {<<"version">>,<<"1.7.103">>}. |
| 3 | + {<<"version">>,<<"1.7.104">>}. |
| 4 4 | {<<"description">>, |
| 5 5 | <<65,32,102,111,117,110,100,97,116,105,111,110,32,102,111,114,32,98,117,105, |
| 6 6 | 108,100,105,110,103,32,69,108,105,120,105,114,32,80,104,111,101,110,105, |
| @@ -17,32 +17,8 @@ | |
| 17 17 | <<"lib/modules/languages/language.ex">>, |
| 18 18 | <<"lib/modules/languages/README.md">>, |
| 19 19 | <<"lib/modules/languages/dialect_mapper.ex">>, |
| 20 | - <<"lib/modules/languages/languages.ex">>,<<"lib/modules/customer_service">>, |
| 21 | - <<"lib/modules/customer_service/ticket_attachment.ex">>, |
| 22 | - <<"lib/modules/customer_service/web">>, |
| 23 | - <<"lib/modules/customer_service/web/settings.html.heex">>, |
| 24 | - <<"lib/modules/customer_service/web/user_new.html.heex">>, |
| 25 | - <<"lib/modules/customer_service/web/user_list.html.heex">>, |
| 26 | - <<"lib/modules/customer_service/web/edit.ex">>, |
| 27 | - <<"lib/modules/customer_service/web/user_details.html.heex">>, |
| 28 | - <<"lib/modules/customer_service/web/new.ex">>, |
| 29 | - <<"lib/modules/customer_service/web/new.html.heex">>, |
| 30 | - <<"lib/modules/customer_service/web/settings.ex">>, |
| 31 | - <<"lib/modules/customer_service/web/details.html.heex">>, |
| 32 | - <<"lib/modules/customer_service/web/user_new.ex">>, |
| 33 | - <<"lib/modules/customer_service/web/list.html.heex">>, |
| 34 | - <<"lib/modules/customer_service/web/details.ex">>, |
| 35 | - <<"lib/modules/customer_service/web/user_list.ex">>, |
| 36 | - <<"lib/modules/customer_service/web/user_details.ex">>, |
| 37 | - <<"lib/modules/customer_service/web/edit.html.heex">>, |
| 38 | - <<"lib/modules/customer_service/web/list.ex">>, |
| 39 | - <<"lib/modules/customer_service/ticket.ex">>, |
| 40 | - <<"lib/modules/customer_service/customer_service.ex">>, |
| 41 | - <<"lib/modules/customer_service/events.ex">>, |
| 42 | - <<"lib/modules/customer_service/ticket_comment.ex">>, |
| 43 | - <<"lib/modules/customer_service/ticket_status_history.ex">>, |
| 44 | - <<"lib/modules/shared">>,<<"lib/modules/shared/README.md">>, |
| 45 | - <<"lib/modules/shared/components">>, |
| 20 | + <<"lib/modules/languages/languages.ex">>,<<"lib/modules/shared">>, |
| 21 | + <<"lib/modules/shared/README.md">>,<<"lib/modules/shared/components">>, |
| 46 22 | <<"lib/modules/shared/components/image.ex">>, |
| 47 23 | <<"lib/modules/shared/components/video.ex">>, |
| 48 24 | <<"lib/modules/shared/components/subheadline.ex">>, |
| @@ -256,7 +232,6 @@ | |
| 256 232 | <<"lib/phoenix_kit_web/plugs/ensure_oauth_config.ex">>, |
| 257 233 | <<"lib/phoenix_kit_web/plugs/ensure_oauth_scheme.ex">>, |
| 258 234 | <<"lib/phoenix_kit_web/routes">>, |
| 259 | - <<"lib/phoenix_kit_web/routes/customer_service.ex">>, |
| 260 235 | <<"lib/phoenix_kit_web/routes/referrals.ex">>, |
| 261 236 | <<"lib/phoenix_kit_web/live">>, |
| 262 237 | <<"lib/phoenix_kit_web/live/settings.html.heex">>, |
| @@ -524,6 +499,7 @@ | |
| 524 499 | <<"lib/phoenix_kit/migrations/postgres/v71.ex">>, |
| 525 500 | <<"lib/phoenix_kit/migrations/postgres/v86.ex">>, |
| 526 501 | <<"lib/phoenix_kit/migrations/postgres/v01.ex">>, |
| 502 | + <<"lib/phoenix_kit/migrations/postgres/v109.ex">>, |
| 527 503 | <<"lib/phoenix_kit/migrations/postgres/v16.ex">>, |
| 528 504 | <<"lib/phoenix_kit/migrations/postgres/v49.ex">>, |
| 529 505 | <<"lib/phoenix_kit/migrations/postgres/v91.ex">>, |
| @@ -1,1071 +0,0 @@ | |
| 1 | - defmodule PhoenixKit.Modules.CustomerService do |
| 2 | - @moduledoc """ |
| 3 | - Context for managing support tickets, comments, and attachments. |
| 4 | - |
| 5 | - Provides complete API for the customer support ticketing system including |
| 6 | - CRUD operations, status workflow, comment threading with internal notes, |
| 7 | - file attachments, and audit trail. |
| 8 | - |
| 9 | - ## Features |
| 10 | - |
| 11 | - - **Ticket Management**: Create, update, delete tickets |
| 12 | - - **Status Workflow**: open → in_progress → resolved → closed |
| 13 | - - **Assignment**: Assign tickets to support staff |
| 14 | - - **Comment System**: Public comments and internal notes |
| 15 | - - **File Attachments**: Multiple files per ticket/comment |
| 16 | - - **Audit Trail**: Complete status change history |
| 17 | - |
| 18 | - ## Status Flow |
| 19 | - |
| 20 | - - `open` - New ticket, awaiting assignment or response |
| 21 | - - `in_progress` - Being worked on by support staff |
| 22 | - - `resolved` - Issue resolved, awaiting confirmation |
| 23 | - - `closed` - Ticket closed (resolved or abandoned) |
| 24 | - |
| 25 | - ## Examples |
| 26 | - |
| 27 | - # Create a ticket |
| 28 | - {:ok, ticket} = Tickets.create_ticket(user_uuid, %{ |
| 29 | - title: "Cannot login", |
| 30 | - description: "I get an error when trying to login..." |
| 31 | - }) |
| 32 | - |
| 33 | - # Assign to support staff |
| 34 | - {:ok, ticket} = Tickets.assign_ticket(ticket, staff_user_uuid, current_user) |
| 35 | - |
| 36 | - # Start working on it |
| 37 | - {:ok, ticket} = Tickets.start_progress(ticket, current_user) |
| 38 | - |
| 39 | - # Add a public comment |
| 40 | - {:ok, comment} = Tickets.create_comment(ticket.uuid, staff_user_uuid, %{ |
| 41 | - content: "We're looking into this issue." |
| 42 | - }) |
| 43 | - |
| 44 | - # Add an internal note (hidden from customer) |
| 45 | - {:ok, note} = Tickets.create_internal_note(ticket.uuid, staff_user_uuid, %{ |
| 46 | - content: "Customer seems frustrated. Need to escalate." |
| 47 | - }) |
| 48 | - |
| 49 | - # Resolve the ticket |
| 50 | - {:ok, ticket} = Tickets.resolve_ticket(ticket, current_user, "Fixed in v2.0.1") |
| 51 | - """ |
| 52 | - |
| 53 | - use PhoenixKit.Module |
| 54 | - |
| 55 | - import Ecto.Query, warn: false |
| 56 | - |
| 57 | - alias PhoenixKit.Dashboard.Tab |
| 58 | - |
| 59 | - alias PhoenixKit.Settings |
| 60 | - alias PhoenixKit.Utils.Date, as: UtilsDate |
| 61 | - |
| 62 | - alias PhoenixKit.Modules.CustomerService.{ |
| 63 | - Events, |
| 64 | - Ticket, |
| 65 | - TicketAttachment, |
| 66 | - TicketComment, |
| 67 | - TicketStatusHistory |
| 68 | - } |
| 69 | - |
| 70 | - # ============================================================================ |
| 71 | - # Module Status |
| 72 | - # ============================================================================ |
| 73 | - |
| 74 | - @impl PhoenixKit.Module |
| 75 | - @doc """ |
| 76 | - Checks if the Tickets module is enabled. |
| 77 | - |
| 78 | - ## Examples |
| 79 | - |
| 80 | - iex> enabled?() |
| 81 | - false |
| 82 | - """ |
| 83 | - def enabled? do |
| 84 | - Settings.get_boolean_setting("customer_service_enabled", false) |
| 85 | - end |
| 86 | - |
| 87 | - @impl PhoenixKit.Module |
| 88 | - @doc """ |
| 89 | - Enables the Tickets module. |
| 90 | - |
| 91 | - ## Examples |
| 92 | - |
| 93 | - iex> enable_system() |
| 94 | - {:ok, %Setting{}} |
| 95 | - """ |
| 96 | - def enable_system do |
| 97 | - result = |
| 98 | - Settings.update_boolean_setting_with_module( |
| 99 | - "customer_service_enabled", |
| 100 | - true, |
| 101 | - "customer_service" |
| 102 | - ) |
| 103 | - |
| 104 | - refresh_dashboard_tabs() |
| 105 | - result |
| 106 | - end |
| 107 | - |
| 108 | - @impl PhoenixKit.Module |
| 109 | - @doc """ |
| 110 | - Disables the Tickets module. |
| 111 | - |
| 112 | - ## Examples |
| 113 | - |
| 114 | - iex> disable_system() |
| 115 | - {:ok, %Setting{}} |
| 116 | - """ |
| 117 | - def disable_system do |
| 118 | - result = |
| 119 | - Settings.update_boolean_setting_with_module( |
| 120 | - "customer_service_enabled", |
| 121 | - false, |
| 122 | - "customer_service" |
| 123 | - ) |
| 124 | - |
| 125 | - refresh_dashboard_tabs() |
| 126 | - result |
| 127 | - end |
| 128 | - |
| 129 | - defp refresh_dashboard_tabs do |
| 130 | - if Code.ensure_loaded?(PhoenixKit.Dashboard.Registry) and |
| 131 | - PhoenixKit.Dashboard.Registry.initialized?() do |
| 132 | - PhoenixKit.Dashboard.Registry.load_defaults() |
| 133 | - end |
| 134 | - end |
| 135 | - |
| 136 | - @impl PhoenixKit.Module |
| 137 | - @doc """ |
| 138 | - Gets the current Tickets module configuration and stats. |
| 139 | - |
| 140 | - ## Examples |
| 141 | - |
| 142 | - iex> get_config() |
| 143 | - %{enabled: false, total_tickets: 0, open_tickets: 0, ...} |
| 144 | - """ |
| 145 | - def get_config do |
| 146 | - %{ |
| 147 | - enabled: enabled?(), |
| 148 | - total_tickets: count_tickets(), |
| 149 | - open_tickets: count_tickets_by_status("open"), |
| 150 | - in_progress_tickets: count_tickets_by_status("in_progress"), |
| 151 | - resolved_tickets: count_tickets_by_status("resolved"), |
| 152 | - closed_tickets: count_tickets_by_status("closed"), |
| 153 | - comments_enabled: Settings.get_boolean_setting("customer_service_comments_enabled", true), |
| 154 | - internal_notes_enabled: |
| 155 | - Settings.get_boolean_setting("customer_service_internal_notes_enabled", true), |
| 156 | - attachments_enabled: |
| 157 | - Settings.get_boolean_setting("customer_service_attachments_enabled", true), |
| 158 | - allow_reopen: Settings.get_boolean_setting("customer_service_allow_reopen", true) |
| 159 | - } |
| 160 | - end |
| 161 | - |
| 162 | - defp count_tickets do |
| 163 | - repo().aggregate(Ticket, :count, :uuid) |
| 164 | - rescue |
| 165 | - _ -> 0 |
| 166 | - end |
| 167 | - |
| 168 | - defp count_tickets_by_status(status) do |
| 169 | - from(t in Ticket, where: t.status == ^status) |
| 170 | - |> repo().aggregate(:count) |
| 171 | - rescue |
| 172 | - _ -> 0 |
| 173 | - end |
| 174 | - |
| 175 | - # ============================================================================ |
| 176 | - # Module Behaviour Callbacks |
| 177 | - # ============================================================================ |
| 178 | - |
| 179 | - @impl PhoenixKit.Module |
| 180 | - def module_key, do: "customer_service" |
| 181 | - |
| 182 | - @impl PhoenixKit.Module |
| 183 | - def module_name, do: "Customer Service" |
| 184 | - |
| 185 | - @impl PhoenixKit.Module |
| 186 | - def permission_metadata do |
| 187 | - %{ |
| 188 | - key: "customer_service", |
| 189 | - label: "Customer Service", |
| 190 | - icon: "hero-lifebuoy", |
| 191 | - description: "Support ticket management and customer communication" |
| 192 | - } |
| 193 | - end |
| 194 | - |
| 195 | - @impl PhoenixKit.Module |
| 196 | - def admin_tabs do |
| 197 | - [ |
| 198 | - Tab.new!( |
| 199 | - id: :admin_customer_service, |
| 200 | - label: "Customer Service", |
| 201 | - icon: "hero-lifebuoy", |
| 202 | - path: "customer-service", |
| 203 | - priority: 620, |
| 204 | - level: :admin, |
| 205 | - permission: "customer_service", |
| 206 | - match: :prefix, |
| 207 | - group: :admin_modules, |
| 208 | - subtab_display: :when_active, |
| 209 | - highlight_with_subtabs: false |
| 210 | - ), |
| 211 | - Tab.new!( |
| 212 | - id: :admin_customer_service_tickets, |
| 213 | - label: "Tickets", |
| 214 | - icon: "hero-ticket", |
| 215 | - path: "customer-service/tickets", |
| 216 | - priority: 621, |
| 217 | - level: :admin, |
| 218 | - permission: "customer_service", |
| 219 | - parent: :admin_customer_service, |
| 220 | - match: :prefix |
| 221 | - ) |
| 222 | - ] |
| 223 | - end |
| 224 | - |
| 225 | - @impl PhoenixKit.Module |
| 226 | - def settings_tabs do |
| 227 | - [ |
| 228 | - Tab.new!( |
| 229 | - id: :admin_settings_customer_service, |
| 230 | - label: "Customer Service", |
| 231 | - icon: "hero-lifebuoy", |
| 232 | - path: "customer-service", |
| 233 | - priority: 923, |
| 234 | - level: :admin, |
| 235 | - parent: :admin_settings, |
| 236 | - permission: "customer_service" |
| 237 | - ) |
| 238 | - ] |
| 239 | - end |
| 240 | - |
| 241 | - @impl PhoenixKit.Module |
| 242 | - def user_dashboard_tabs do |
| 243 | - [ |
| 244 | - Tab.new!( |
| 245 | - id: :dashboard_customer_service_tickets, |
| 246 | - label: "My Tickets", |
| 247 | - icon: "hero-ticket", |
| 248 | - path: "customer-service/tickets", |
| 249 | - priority: 800, |
| 250 | - match: :prefix, |
| 251 | - group: :account |
| 252 | - ) |
| 253 | - ] |
| 254 | - end |
| 255 | - |
| 256 | - @impl PhoenixKit.Module |
| 257 | - def route_module, do: PhoenixKitWeb.Routes.CustomerServiceRoutes |
| 258 | - |
| 259 | - # ============================================================================ |
| 260 | - # Ticket CRUD Operations |
| 261 | - # ============================================================================ |
| 262 | - |
| 263 | - @doc """ |
| 264 | - Creates a new ticket. |
| 265 | - |
| 266 | - ## Parameters |
| 267 | - |
| 268 | - - `user_uuid` - Customer who created the ticket |
| 269 | - - `attrs` - Ticket attributes (title, description) |
| 270 | - |
| 271 | - ## Examples |
| 272 | - |
| 273 | - iex> create_ticket(42, %{title: "Bug report", description: "Something is wrong"}) |
| 274 | - {:ok, %Ticket{}} |
| 275 | - |
| 276 | - iex> create_ticket(42, %{title: ""}) |
| 277 | - {:error, %Ecto.Changeset{}} |
| 278 | - """ |
| 279 | - def create_ticket(user_uuid, attrs) when is_binary(user_uuid) do |
| 280 | - create_ticket_with_uuid(user_uuid, attrs) |
| 281 | - end |
| 282 | - |
| 283 | - defp create_ticket_with_uuid(user_uuid, attrs) do |
| 284 | - attrs = |
| 285 | - attrs |
| 286 | - |> Map.put("user_uuid", user_uuid) |
| 287 | - |> Map.put("status", "open") |
| 288 | - |
| 289 | - repo().transaction(fn -> |
| 290 | - case %Ticket{} |
| 291 | - |> Ticket.changeset(attrs) |
| 292 | - |> repo().insert() do |
| 293 | - {:ok, ticket} -> |
| 294 | - create_status_history(ticket.uuid, user_uuid, nil, "open", nil) |
| 295 | - Events.broadcast_ticket_created(ticket) |
| 296 | - ticket |
| 297 | - |
| 298 | - {:error, changeset} -> |
| 299 | - repo().rollback(changeset) |
| 300 | - end |
| 301 | - end) |
| 302 | - end |
| 303 | - |
| 304 | - @doc """ |
| 305 | - Updates an existing ticket. |
| 306 | - |
| 307 | - ## Parameters |
| 308 | - |
| 309 | - - `ticket` - Ticket struct to update |
| 310 | - - `attrs` - Attributes to update |
| 311 | - |
| 312 | - ## Examples |
| 313 | - |
| 314 | - iex> update_ticket(ticket, %{title: "Updated Title"}) |
| 315 | - {:ok, %Ticket{}} |
| 316 | - """ |
| 317 | - def update_ticket(%Ticket{} = ticket, attrs) do |
| 318 | - ticket |
| 319 | - |> Ticket.changeset(attrs) |
| 320 | - |> repo().update() |
| 321 | - |> case do |
| 322 | - {:ok, updated_ticket} -> |
| 323 | - Events.broadcast_ticket_updated(updated_ticket) |
| 324 | - {:ok, updated_ticket} |
| 325 | - |
| 326 | - error -> |
| 327 | - error |
| 328 | - end |
| 329 | - end |
| 330 | - |
| 331 | - @doc """ |
| 332 | - Deletes a ticket and all related data. |
| 333 | - |
| 334 | - ## Parameters |
| 335 | - |
| 336 | - - `ticket` - Ticket struct to delete |
| 337 | - |
| 338 | - ## Examples |
| 339 | - |
| 340 | - iex> delete_ticket(ticket) |
| 341 | - {:ok, %Ticket{}} |
| 342 | - """ |
| 343 | - def delete_ticket(%Ticket{} = ticket) do |
| 344 | - repo().delete(ticket) |
| 345 | - end |
| 346 | - |
| 347 | - @doc """ |
| 348 | - Gets a single ticket by ID with optional preloads. |
| 349 | - |
| 350 | - Raises `Ecto.NoResultsError` if ticket not found. |
| 351 | - |
| 352 | - ## Parameters |
| 353 | - |
| 354 | - - `id` - Ticket ID (UUIDv7) |
| 355 | - - `opts` - Options |
| 356 | - - `:preload` - List of associations to preload |
| 357 | - |
| 358 | - ## Examples |
| 359 | - |
| 360 | - iex> get_ticket!("018e3c4a-...") |
| 361 | - %Ticket{} |
| 362 | - |
| 363 | - iex> get_ticket!("018e3c4a-...", preload: [:user, :assigned_to, :comments]) |
| 364 | - %Ticket{user: %User{}, assigned_to: %User{}, comments: [...]} |
| 365 | - """ |
| 366 | - def get_ticket!(id, opts \\ []) do |
| 367 | - preloads = Keyword.get(opts, :preload, []) |
| 368 | - |
| 369 | - Ticket |
| 370 | - |> repo().get!(id) |
| 371 | - |> repo().preload(preloads) |
| 372 | - end |
| 373 | - |
| 374 | - @doc """ |
| 375 | - Gets a single ticket by ID. Returns nil if not found. |
| 376 | - """ |
| 377 | - def get_ticket(id, opts \\ []) do |
| 378 | - preloads = Keyword.get(opts, :preload, []) |
| 379 | - |
| 380 | - case repo().get(Ticket, id) do |
| 381 | - nil -> nil |
| 382 | - ticket -> repo().preload(ticket, preloads) |
| 383 | - end |
| 384 | - end |
| 385 | - |
| 386 | - @doc """ |
| 387 | - Gets a single ticket by slug. |
| 388 | - |
| 389 | - ## Parameters |
| 390 | - |
| 391 | - - `slug` - Ticket slug |
| 392 | - - `opts` - Options |
| 393 | - - `:preload` - List of associations to preload |
| 394 | - |
| 395 | - ## Examples |
| 396 | - |
| 397 | - iex> get_ticket_by_slug("cannot-login-123456") |
| 398 | - %Ticket{} |
| 399 | - """ |
| 400 | - def get_ticket_by_slug(slug, opts \\ []) when is_binary(slug) do |
| 401 | - preloads = Keyword.get(opts, :preload, []) |
| 402 | - |
| 403 | - Ticket |
| 404 | - |> where([t], t.slug == ^slug) |
| 405 | - |> repo().one() |
| 406 | - |> case do |
| 407 | - nil -> nil |
| 408 | - ticket -> repo().preload(ticket, preloads) |
| 409 | - end |
| 410 | - end |
| 411 | - |
| 412 | - @doc """ |
| 413 | - Lists tickets with optional filtering and pagination. |
| 414 | - |
| 415 | - ## Parameters |
| 416 | - |
| 417 | - - `opts` - Options |
| 418 | - - `:user_uuid` - Filter by customer (ticket creator) |
| 419 | - - `:assigned_to_uuid` - Filter by assigned handler |
| 420 | - - `:status` - Filter by status (open/in_progress/resolved/closed) |
| 421 | - - `:search` - Search in title and description |
| 422 | - - `:page` - Page number (default: 1) |
| 423 | - - `:per_page` - Items per page (default: 20) |
| 424 | - - `:preload` - Associations to preload |
| 425 | - |
| 426 | - ## Examples |
| 427 | - |
| 428 | - iex> list_tickets() |
| 429 | - [%Ticket{}, ...] |
| 430 | - |
| 431 | - iex> list_tickets(status: "open", assigned_to_uuid: nil) |
| 432 | - [%Ticket{}, ...] |
| 433 | - """ |
| 434 | - def list_tickets(opts \\ []) do |
| 435 | - user_uuid = Keyword.get(opts, :user_uuid) |
| 436 | - assigned_to_uuid = Keyword.get(opts, :assigned_to_uuid) |
| 437 | - status = Keyword.get(opts, :status) |
| 438 | - search = Keyword.get(opts, :search) |
| 439 | - preloads = Keyword.get(opts, :preload, []) |
| 440 | - page = Keyword.get(opts, :page, 1) |
| 441 | - per_page = Keyword.get(opts, :per_page, 20) |
| 442 | - |
| 443 | - Ticket |
| 444 | - |> maybe_filter_by_user(user_uuid) |
| 445 | - |> maybe_filter_by_assigned_to(assigned_to_uuid) |
| 446 | - |> maybe_filter_by_status(status) |
| 447 | - |> maybe_search_tickets(search) |
| 448 | - |> order_by([t], desc: t.inserted_at) |
| 449 | - |> paginate(page, per_page) |
| 450 | - |> repo().all() |
| 451 | - |> repo().preload(preloads) |
| 452 | - end |
| 453 | - |
| 454 | - @doc """ |
| 455 | - Lists unassigned tickets (where assigned_to_uuid is nil). |
| 456 | - """ |
| 457 | - def list_unassigned_tickets(opts \\ []) do |
| 458 | - opts = Keyword.put(opts, :assigned_to_uuid, nil) |
| 459 | - list_tickets(opts) |
| 460 | - end |
| 461 | - |
| 462 | - @doc """ |
| 463 | - Lists tickets assigned to a specific handler. |
| 464 | - """ |
| 465 | - def list_tickets_assigned_to(handler_uuid, opts \\ []) when is_binary(handler_uuid) do |
| 466 | - list_tickets(Keyword.put(opts, :assigned_to_uuid, handler_uuid)) |
| 467 | - end |
| 468 | - |
| 469 | - @doc """ |
| 470 | - Lists tickets created by a specific user. |
| 471 | - """ |
| 472 | - def list_user_tickets(user_uuid, opts \\ []) when is_binary(user_uuid) do |
| 473 | - list_tickets(Keyword.put(opts, :user_uuid, user_uuid)) |
| 474 | - end |
| 475 | - |
| 476 | - # ============================================================================ |
| 477 | - # Status Transitions |
| 478 | - # ============================================================================ |
| 479 | - |
| 480 | - @doc """ |
| 481 | - Assigns a ticket to a support staff member. |
| 482 | - |
| 483 | - If the ticket is open, it will be moved to in_progress. |
| 484 | - |
| 485 | - ## Parameters |
| 486 | - |
| 487 | - - `ticket` - Ticket to assign |
| 488 | - - `handler_uuid` - UUID of the support staff |
| 489 | - - `changed_by` - User making the change |
| 490 | - |
| 491 | - ## Examples |
| 492 | - |
| 493 | - iex> assign_ticket(ticket, handler_uuid, current_user) |
| 494 | - {:ok, %Ticket{assigned_to_uuid: handler_uuid}} |
| 495 | - """ |
| 496 | - def assign_ticket(%Ticket{} = ticket, handler_uuid, changed_by) |
| 497 | - when is_binary(handler_uuid) do |
| 498 | - changed_by_uuid = get_user_uuid(changed_by) |
| 499 | - old_assignee_uuid = ticket.assigned_to_uuid |
| 500 | - |
| 501 | - repo().transaction(fn -> |
| 502 | - attrs = %{assigned_to_uuid: handler_uuid} |
| 503 | - |
| 504 | - # If ticket is open, move to in_progress |
| 505 | - {attrs, new_status} = |
| 506 | - if ticket.status == "open" do |
| 507 | - {Map.put(attrs, :status, "in_progress"), "in_progress"} |
| 508 | - else |
| 509 | - {attrs, nil} |
| 510 | - end |
| 511 | - |
| 512 | - case update_ticket(ticket, attrs) do |
| 513 | - {:ok, updated_ticket} -> |
| 514 | - if new_status do |
| 515 | - create_status_history( |
| 516 | - ticket.uuid, |
| 517 | - changed_by_uuid, |
| 518 | - ticket.status, |
| 519 | - new_status, |
| 520 | - "Assigned to handler" |
| 521 | - ) |
| 522 | - end |
| 523 | - |
| 524 | - # Broadcast assignment event |
| 525 | - Events.broadcast_ticket_assigned(updated_ticket, old_assignee_uuid, handler_uuid) |
| 526 | - |
| 527 | - updated_ticket |
| 528 | - |
| 529 | - {:error, changeset} -> |
| 530 | - repo().rollback(changeset) |
| 531 | - end |
| 532 | - end) |
| 533 | - end |
| 534 | - |
| 535 | - @doc """ |
| 536 | - Moves ticket to in_progress status. |
| 537 | - |
| 538 | - ## Parameters |
| 539 | - |
| 540 | - - `ticket` - Ticket to update |
| 541 | - - `changed_by` - User making the change |
| 542 | - |
| 543 | - ## Examples |
| 544 | - |
| 545 | - iex> start_progress(ticket, current_user) |
| 546 | - {:ok, %Ticket{status: "in_progress"}} |
| 547 | - """ |
| 548 | - def start_progress(%Ticket{} = ticket, changed_by) do |
| 549 | - transition_status(ticket, "in_progress", changed_by) |
| 550 | - end |
| 551 | - |
| 552 | - @doc """ |
| 553 | - Resolves a ticket. |
| 554 | - |
| 555 | - ## Parameters |
| 556 | - |
| 557 | - - `ticket` - Ticket to resolve |
| 558 | - - `changed_by` - User making the change |
| 559 | - - `reason` - Optional resolution reason |
| 560 | - |
| 561 | - ## Examples |
| 562 | - |
| 563 | - iex> resolve_ticket(ticket, current_user, "Fixed in version 2.0.1") |
| 564 | - {:ok, %Ticket{status: "resolved"}} |
| 565 | - """ |
| 566 | - def resolve_ticket(%Ticket{} = ticket, changed_by, reason \\ nil) do |
| 567 | - transition_status(ticket, "resolved", changed_by, reason) |
| 568 | - end |
| 569 | - |
| 570 | - @doc """ |
| 571 | - Closes a ticket. |
| 572 | - |
| 573 | - ## Parameters |
| 574 | - |
| 575 | - - `ticket` - Ticket to close |
| 576 | - - `changed_by` - User making the change |
| 577 | - - `reason` - Optional close reason |
| 578 | - |
| 579 | - ## Examples |
| 580 | - |
| 581 | - iex> close_ticket(ticket, current_user, "No response from customer") |
| 582 | - {:ok, %Ticket{status: "closed"}} |
| 583 | - """ |
| 584 | - def close_ticket(%Ticket{} = ticket, changed_by, reason \\ nil) do |
| 585 | - transition_status(ticket, "closed", changed_by, reason) |
| 586 | - end |
| 587 | - |
| 588 | - @doc """ |
| 589 | - Reopens a closed or resolved ticket. |
| 590 | - |
| 591 | - ## Parameters |
| 592 | - |
| 593 | - - `ticket` - Ticket to reopen |
| 594 | - - `changed_by` - User making the change |
| 595 | - - `reason` - Optional reopen reason |
| 596 | - |
| 597 | - ## Examples |
| 598 | - |
| 599 | - iex> reopen_ticket(ticket, current_user, "Issue still occurring") |
| 600 | - {:ok, %Ticket{status: "open"}} |
| 601 | - """ |
| 602 | - def reopen_ticket(%Ticket{} = ticket, changed_by, reason \\ nil) do |
| 603 | - if Settings.get_boolean_setting("customer_service_allow_reopen", true) do |
| 604 | - transition_status(ticket, "open", changed_by, reason) |
| 605 | - else |
| 606 | - {:error, :reopen_not_allowed} |
| 607 | - end |
| 608 | - end |
| 609 | - |
| 610 | - defp transition_status(%Ticket{} = ticket, new_status, changed_by, reason \\ nil) do |
| 611 | - changed_by_uuid = get_user_uuid(changed_by) |
| 612 | - old_status = ticket.status |
| 613 | - |
| 614 | - if Ticket.valid_transition?(old_status, new_status) do |
| 615 | - repo().transaction(fn -> |
| 616 | - attrs = %{status: new_status} |
| 617 | - |
| 618 | - # Set timestamps based on new status |
| 619 | - attrs = |
| 620 | - case new_status do |
| 621 | - "resolved" -> |
| 622 | - Map.put(attrs, :resolved_at, UtilsDate.utc_now()) |
| 623 | - |
| 624 | - "closed" -> |
| 625 | - Map.put(attrs, :closed_at, UtilsDate.utc_now()) |
| 626 | - |
| 627 | - "open" -> |
| 628 | - Map.merge(attrs, %{resolved_at: nil, closed_at: nil}) |
| 629 | - |
| 630 | - _ -> |
| 631 | - attrs |
| 632 | - end |
| 633 | - |
| 634 | - # Use raw update to avoid double broadcast from update_ticket |
| 635 | - case ticket |
| 636 | - |> Ticket.changeset(attrs) |
| 637 | - |> repo().update() do |
| 638 | - {:ok, updated_ticket} -> |
| 639 | - create_status_history(ticket.uuid, changed_by_uuid, old_status, new_status, reason) |
| 640 | - |
| 641 | - # Broadcast status change event |
| 642 | - Events.broadcast_ticket_status_changed(updated_ticket, old_status, new_status) |
| 643 | - |
| 644 | - updated_ticket |
| 645 | - |
| 646 | - {:error, changeset} -> |
| 647 | - repo().rollback(changeset) |
| 648 | - end |
| 649 | - end) |
| 650 | - else |
| 651 | - {:error, :invalid_transition} |
| 652 | - end |
| 653 | - end |
| 654 | - |
| 655 | - defp create_status_history(ticket_uuid, changed_by_uuid, from_status, to_status, reason) do |
| 656 | - %TicketStatusHistory{} |
| 657 | - |> TicketStatusHistory.changeset(%{ |
| 658 | - ticket_uuid: ticket_uuid, |
| 659 | - changed_by_uuid: changed_by_uuid, |
| 660 | - from_status: from_status, |
| 661 | - to_status: to_status, |
| 662 | - reason: reason |
| 663 | - }) |
| 664 | - |> repo().insert() |
| 665 | - end |
| 666 | - |
| 667 | - # ============================================================================ |
| 668 | - # Comments |
| 669 | - # ============================================================================ |
| 670 | - |
| 671 | - @doc """ |
| 672 | - Creates a public comment on a ticket. |
| 673 | - |
| 674 | - ## Parameters |
| 675 | - |
| 676 | - - `ticket_uuid` - ID of the ticket |
| 677 | - - `user_uuid` - ID of the commenter |
| 678 | - - `attrs` - Comment attributes (content, optional parent_id) |
| 679 | - |
| 680 | - ## Examples |
| 681 | - |
| 682 | - iex> create_comment(ticket.uuid, user_uuid, %{content: "Thanks for looking into this!"}) |
| 683 | - {:ok, %TicketComment{}} |
| 684 | - """ |
| 685 | - def create_comment(ticket_uuid, user_uuid, attrs) when is_binary(user_uuid) do |
| 686 | - # Accept UUID strings directly |
| 687 | - attrs = |
| 688 | - attrs |
| 689 | - |> ensure_string_keys() |
| 690 | - |> Map.put("ticket_uuid", ticket_uuid) |
| 691 | - |> Map.put("user_uuid", user_uuid) |
| 692 | - |> Map.put("is_internal", false) |
| 693 | - |
| 694 | - attrs = maybe_calculate_depth(attrs) |
| 695 | - |
| 696 | - repo().transaction(fn -> |
| 697 | - case %TicketComment{} |
| 698 | - |> TicketComment.changeset(attrs) |
| 699 | - |> repo().insert() do |
| 700 | - {:ok, comment} -> |
| 701 | - increment_comment_count(ticket_uuid) |
| 702 | - |
| 703 | - # Load ticket for broadcast |
| 704 | - ticket = repo().get(Ticket, ticket_uuid) |
| 705 | - |
| 706 | - # Broadcast comment created event |
| 707 | - Events.broadcast_comment_created(comment, ticket) |
| 708 | - |
| 709 | - comment |
| 710 | - |
| 711 | - {:error, changeset} -> |
| 712 | - repo().rollback(changeset) |
| 713 | - end |
| 714 | - end) |
| 715 | - end |
| 716 | - |
| 717 | - @doc """ |
| 718 | - Creates an internal note on a ticket (visible only to support staff). |
| 719 | - |
| 720 | - ## Parameters |
| 721 | - |
| 722 | - - `ticket_uuid` - ID of the ticket |
| 723 | - - `user_uuid` - ID of the staff member |
| 724 | - - `attrs` - Note attributes (content) |
| 725 | - |
| 726 | - ## Examples |
| 727 | - |
| 728 | - iex> create_internal_note(ticket.uuid, staff_uuid, %{content: "Customer seems frustrated"}) |
| 729 | - {:ok, %TicketComment{is_internal: true}} |
| 730 | - """ |
| 731 | - def create_internal_note(ticket_uuid, user_uuid, attrs) when is_binary(user_uuid) do |
| 732 | - # Accept UUID strings directly |
| 733 | - attrs = |
| 734 | - attrs |
| 735 | - |> ensure_string_keys() |
| 736 | - |> Map.put("ticket_uuid", ticket_uuid) |
| 737 | - |> Map.put("user_uuid", user_uuid) |
| 738 | - |> Map.put("is_internal", true) |
| 739 | - |
| 740 | - attrs = maybe_calculate_depth(attrs) |
| 741 | - |
| 742 | - %TicketComment{} |
| 743 | - |> TicketComment.changeset(attrs) |
| 744 | - |> repo().insert() |
| 745 | - |> case do |
| 746 | - {:ok, comment} -> |
| 747 | - # Load ticket for broadcast |
| 748 | - ticket = repo().get(Ticket, ticket_uuid) |
| 749 | - |
| 750 | - # Broadcast internal note created event |
| 751 | - Events.broadcast_internal_note_created(comment, ticket) |
| 752 | - |
| 753 | - {:ok, comment} |
| 754 | - |
| 755 | - error -> |
| 756 | - error |
| 757 | - end |
| 758 | - end |
| 759 | - |
| 760 | - @doc """ |
| 761 | - Updates a comment. |
| 762 | - """ |
| 763 | - def update_comment(%TicketComment{} = comment, attrs) do |
| 764 | - comment |
| 765 | - |> TicketComment.changeset(attrs) |
| 766 | - |> repo().update() |
| 767 | - end |
| 768 | - |
| 769 | - @doc """ |
| 770 | - Deletes a comment. |
| 771 | - """ |
| 772 | - def delete_comment(%TicketComment{} = comment) do |
| 773 | - repo().transaction(fn -> |
| 774 | - case repo().delete(comment) do |
| 775 | - {:ok, deleted} -> |
| 776 | - unless comment.is_internal do |
| 777 | - decrement_comment_count(comment.ticket_uuid) |
| 778 | - end |
| 779 | - |
| 780 | - deleted |
| 781 | - |
| 782 | - {:error, changeset} -> |
| 783 | - repo().rollback(changeset) |
| 784 | - end |
| 785 | - end) |
| 786 | - end |
| 787 | - |
| 788 | - @doc """ |
| 789 | - Gets a comment by ID. |
| 790 | - """ |
| 791 | - def get_comment!(id, opts \\ []) do |
| 792 | - preloads = Keyword.get(opts, :preload, []) |
| 793 | - |
| 794 | - TicketComment |
| 795 | - |> repo().get!(id) |
| 796 | - |> repo().preload(preloads) |
| 797 | - end |
| 798 | - |
| 799 | - @doc """ |
| 800 | - Lists public comments for a ticket (excludes internal notes). |
| 801 | - """ |
| 802 | - def list_public_comments(ticket_uuid, opts \\ []) do |
| 803 | - preloads = Keyword.get(opts, :preload, [:user]) |
| 804 | - |
| 805 | - from(c in TicketComment, |
| 806 | - where: c.ticket_uuid == ^ticket_uuid and c.is_internal == false, |
| 807 | - order_by: [asc: c.inserted_at] |
| 808 | - ) |
| 809 | - |> repo().all() |
| 810 | - |> repo().preload(preloads) |
| 811 | - end |
| 812 | - |
| 813 | - @doc """ |
| 814 | - Lists all comments for a ticket (includes internal notes). |
| 815 | - For staff use only. |
| 816 | - """ |
| 817 | - def list_all_comments(ticket_uuid, opts \\ []) do |
| 818 | - preloads = Keyword.get(opts, :preload, [:user]) |
| 819 | - |
| 820 | - from(c in TicketComment, |
| 821 | - where: c.ticket_uuid == ^ticket_uuid, |
| 822 | - order_by: [asc: c.inserted_at] |
| 823 | - ) |
| 824 | - |> repo().all() |
| 825 | - |> repo().preload(preloads) |
| 826 | - end |
| 827 | - |
| 828 | - @doc """ |
| 829 | - Lists only internal notes for a ticket. |
| 830 | - """ |
| 831 | - def list_internal_notes(ticket_uuid, opts \\ []) do |
| 832 | - preloads = Keyword.get(opts, :preload, [:user]) |
| 833 | - |
| 834 | - from(c in TicketComment, |
| 835 | - where: c.ticket_uuid == ^ticket_uuid and c.is_internal == true, |
| 836 | - order_by: [asc: c.inserted_at] |
| 837 | - ) |
| 838 | - |> repo().all() |
| 839 | - |> repo().preload(preloads) |
| 840 | - end |
| 841 | - |
| 842 | - defp maybe_calculate_depth(attrs) do |
| 843 | - parent_uuid = Map.get(attrs, "parent_uuid") || Map.get(attrs, :parent_uuid) |
| 844 | - |
| 845 | - if parent_uuid do |
| 846 | - parent = repo().get!(TicketComment, parent_uuid) |
| 847 | - Map.put(attrs, "depth", parent.depth + 1) |
| 848 | - else |
| 849 | - Map.put(attrs, "depth", 0) |
| 850 | - end |
| 851 | - end |
| 852 | - |
| 853 | - defp increment_comment_count(ticket_uuid) do |
| 854 | - from(t in Ticket, where: t.uuid == ^ticket_uuid) |
| 855 | - |> repo().update_all(inc: [comment_count: 1]) |
| 856 | - end |
| 857 | - |
| 858 | - defp decrement_comment_count(ticket_uuid) do |
| 859 | - from(t in Ticket, where: t.uuid == ^ticket_uuid) |
| 860 | - |> repo().update_all(inc: [comment_count: -1]) |
| 861 | - end |
| 862 | - |
| 863 | - defp ensure_string_keys(map) when is_map(map) do |
| 864 | - Map.new(map, fn |
| 865 | - {k, v} when is_atom(k) -> {Atom.to_string(k), v} |
| 866 | - {k, v} -> {k, v} |
| 867 | - end) |
| 868 | - end |
| 869 | - |
| 870 | - # ============================================================================ |
| 871 | - # Attachments |
| 872 | - # ============================================================================ |
| 873 | - |
| 874 | - @doc """ |
| 875 | - Attaches a file to a ticket. |
| 876 | - |
| 877 | - ## Parameters |
| 878 | - |
| 879 | - - `ticket_uuid` - ID of the ticket |
| 880 | - - `file_uuid` - UUID of the uploaded file |
| 881 | - - `opts` - Options |
| 882 | - - `:position` - Display order (default: auto-calculated) |
| 883 | - - `:caption` - Optional caption |
| 884 | - |
| 885 | - ## Examples |
| 886 | - |
| 887 | - iex> add_attachment_to_ticket(ticket.uuid, file.uuid, caption: "Error screenshot") |
| 888 | - {:ok, %TicketAttachment{}} |
| 889 | - """ |
| 890 | - def add_attachment_to_ticket(ticket_uuid, file_uuid, opts \\ []) do |
| 891 | - position = Keyword.get(opts, :position) || next_ticket_attachment_position(ticket_uuid) |
| 892 | - caption = Keyword.get(opts, :caption) |
| 893 | - |
| 894 | - %TicketAttachment{} |
| 895 | - |> TicketAttachment.changeset(%{ |
| 896 | - ticket_uuid: ticket_uuid, |
| 897 | - file_uuid: file_uuid, |
| 898 | - position: position, |
| 899 | - caption: caption |
| 900 | - }) |
| 901 | - |> repo().insert() |
| 902 | - end |
| 903 | - |
| 904 | - @doc """ |
| 905 | - Attaches a file to a comment. |
| 906 | - """ |
| 907 | - def add_attachment_to_comment(comment_uuid, file_uuid, opts \\ []) do |
| 908 | - position = Keyword.get(opts, :position) || next_comment_attachment_position(comment_uuid) |
| 909 | - caption = Keyword.get(opts, :caption) |
| 910 | - |
| 911 | - %TicketAttachment{} |
| 912 | - |> TicketAttachment.changeset(%{ |
| 913 | - comment_uuid: comment_uuid, |
| 914 | - file_uuid: file_uuid, |
| 915 | - position: position, |
| 916 | - caption: caption |
| 917 | - }) |
| 918 | - |> repo().insert() |
| 919 | - end |
| 920 | - |
| 921 | - @doc """ |
| 922 | - Removes an attachment. |
| 923 | - """ |
| 924 | - def remove_attachment(attachment_uuid) do |
| 925 | - attachment = repo().get!(TicketAttachment, attachment_uuid) |
| 926 | - repo().delete(attachment) |
| 927 | - end |
| 928 | - |
| 929 | - @doc """ |
| 930 | - Lists attachments for a ticket. |
| 931 | - """ |
| 932 | - def list_ticket_attachments(ticket_uuid, opts \\ []) do |
| 933 | - preloads = Keyword.get(opts, :preload, [:file]) |
| 934 | - |
| 935 | - from(a in TicketAttachment, |
| 936 | - where: a.ticket_uuid == ^ticket_uuid and is_nil(a.comment_uuid), |
| 937 | - order_by: [asc: a.position] |
| 938 | - ) |
| 939 | - |> repo().all() |
| 940 | - |> repo().preload(preloads) |
| 941 | - end |
| 942 | - |
| 943 | - @doc """ |
| 944 | - Lists attachments for a comment. |
| 945 | - """ |
| 946 | - def list_comment_attachments(comment_uuid, opts \\ []) do |
| 947 | - preloads = Keyword.get(opts, :preload, [:file]) |
| 948 | - |
| 949 | - from(a in TicketAttachment, |
| 950 | - where: a.comment_uuid == ^comment_uuid, |
| 951 | - order_by: [asc: a.position] |
| 952 | - ) |
| 953 | - |> repo().all() |
| 954 | - |> repo().preload(preloads) |
| 955 | - end |
| 956 | - |
| 957 | - defp next_ticket_attachment_position(ticket_uuid) do |
| 958 | - from(a in TicketAttachment, |
| 959 | - where: a.ticket_uuid == ^ticket_uuid and is_nil(a.comment_uuid), |
| 960 | - select: coalesce(max(a.position), 0) |
| 961 | - ) |
| 962 | - |> repo().one() |
| 963 | - |> Kernel.+(1) |
| 964 | - end |
| 965 | - |
| 966 | - defp next_comment_attachment_position(comment_uuid) do |
| 967 | - from(a in TicketAttachment, |
| 968 | - where: a.comment_uuid == ^comment_uuid, |
| 969 | - select: coalesce(max(a.position), 0) |
| 970 | - ) |
| 971 | - |> repo().one() |
| 972 | - |> Kernel.+(1) |
| 973 | - end |
| 974 | - |
| 975 | - # ============================================================================ |
| 976 | - # Status History |
| 977 | - # ============================================================================ |
| 978 | - |
| 979 | - @doc """ |
| 980 | - Gets the status history for a ticket. |
| 981 | - """ |
| 982 | - def get_status_history(ticket_uuid, opts \\ []) do |
| 983 | - preloads = Keyword.get(opts, :preload, [:changed_by]) |
| 984 | - |
| 985 | - from(h in TicketStatusHistory, |
| 986 | - where: h.ticket_uuid == ^ticket_uuid, |
| 987 | - order_by: [asc: h.inserted_at] |
| 988 | - ) |
| 989 | - |> repo().all() |
| 990 | - |> repo().preload(preloads) |
| 991 | - end |
| 992 | - |
| 993 | - # ============================================================================ |
| 994 | - # Statistics |
| 995 | - # ============================================================================ |
| 996 | - |
| 997 | - @doc """ |
| 998 | - Gets ticket statistics. |
| 999 | - """ |
| 1000 | - def get_stats do |
| 1001 | - %{ |
| 1002 | - total: count_tickets(), |
| 1003 | - open: count_tickets_by_status("open"), |
| 1004 | - in_progress: count_tickets_by_status("in_progress"), |
| 1005 | - resolved: count_tickets_by_status("resolved"), |
| 1006 | - closed: count_tickets_by_status("closed"), |
| 1007 | - unassigned: count_unassigned_tickets() |
| 1008 | - } |
| 1009 | - end |
| 1010 | - |
| 1011 | - defp count_unassigned_tickets do |
| 1012 | - from(t in Ticket, where: is_nil(t.assigned_to_uuid) and t.status in ["open", "in_progress"]) |
| 1013 | - |> repo().aggregate(:count) |
| 1014 | - rescue |
| 1015 | - _ -> 0 |
| 1016 | - end |
| 1017 | - |
| 1018 | - # ============================================================================ |
| 1019 | - # Private Helpers |
| 1020 | - # ============================================================================ |
| 1021 | - |
| 1022 | - defp maybe_filter_by_user(query, nil), do: query |
| 1023 | - |
| 1024 | - defp maybe_filter_by_user(query, user_uuid) when is_binary(user_uuid) do |
| 1025 | - where(query, [t], t.user_uuid == ^user_uuid) |
| 1026 | - end |
| 1027 | - |
| 1028 | - defp maybe_filter_by_assigned_to(query, nil), do: query |
| 1029 | - |
| 1030 | - defp maybe_filter_by_assigned_to(query, :unassigned) do |
| 1031 | - where(query, [t], is_nil(t.assigned_to_uuid)) |
| 1032 | - end |
| 1033 | - |
| 1034 | - defp maybe_filter_by_assigned_to(query, assigned_to_uuid) when is_binary(assigned_to_uuid) do |
| 1035 | - where(query, [t], t.assigned_to_uuid == ^assigned_to_uuid) |
| 1036 | - end |
| 1037 | - |
| 1038 | - defp maybe_filter_by_status(query, nil), do: query |
| 1039 | - |
| 1040 | - defp maybe_filter_by_status(query, status) when is_binary(status) do |
| 1041 | - where(query, [t], t.status == ^status) |
| 1042 | - end |
| 1043 | - |
| 1044 | - defp maybe_filter_by_status(query, statuses) when is_list(statuses) do |
| 1045 | - where(query, [t], t.status in ^statuses) |
| 1046 | - end |
| 1047 | - |
| 1048 | - defp maybe_search_tickets(query, nil), do: query |
| 1049 | - defp maybe_search_tickets(query, ""), do: query |
| 1050 | - |
| 1051 | - defp maybe_search_tickets(query, search) do |
| 1052 | - search_pattern = "%#{search}%" |
| 1053 | - where(query, [t], ilike(t.title, ^search_pattern) or ilike(t.description, ^search_pattern)) |
| 1054 | - end |
| 1055 | - |
| 1056 | - defp paginate(query, page, per_page) do |
| 1057 | - offset = (page - 1) * per_page |
| 1058 | - |
| 1059 | - query |
| 1060 | - |> limit(^per_page) |
| 1061 | - |> offset(^offset) |
| 1062 | - end |
| 1063 | - |
| 1064 | - defp get_user_uuid(%{uuid: uuid}), do: uuid |
| 1065 | - defp get_user_uuid(uuid) when is_binary(uuid), do: uuid |
| 1066 | - defp get_user_uuid(_), do: nil |
| 1067 | - |
| 1068 | - defp repo do |
| 1069 | - PhoenixKit.RepoHelper.repo() |
| 1070 | - end |
| 1071 | - end |
| @@ -1,224 +0,0 @@ | |
| 1 | - defmodule PhoenixKit.Modules.CustomerService.Events do |
| 2 | - @moduledoc """ |
| 3 | - PubSub events for PhoenixKit Customer Service system. |
| 4 | - |
| 5 | - Broadcasts ticket-related events for real-time updates in LiveViews. |
| 6 | - Uses `PhoenixKit.PubSub.Manager` for self-contained PubSub operations. |
| 7 | - |
| 8 | - ## Topics |
| 9 | - |
| 10 | - - `"customer_service:all"` - All tickets (for admins) |
| 11 | - - `"customer_service:user:{user_uuid}"` - Tickets for specific user |
| 12 | - - `"customer_service:{uuid}"` - Specific ticket (for detail view) |
| 13 | - |
| 14 | - ## Events |
| 15 | - |
| 16 | - ### Ticket Events |
| 17 | - - `{:ticket_created, ticket}` - New ticket created |
| 18 | - - `{:ticket_updated, ticket}` - Ticket updated |
| 19 | - - `{:ticket_status_changed, ticket, old_status, new_status}` - Status transition |
| 20 | - - `{:ticket_assigned, ticket, old_assignee_uuid, new_assignee_uuid}` - Assignment change |
| 21 | - - `{:ticket_priority_changed, ticket, old_priority, new_priority}` - Priority change |
| 22 | - - `{:tickets_bulk_updated, tickets, changes}` - Bulk update operation |
| 23 | - |
| 24 | - ### Comment Events |
| 25 | - - `{:comment_created, comment, ticket}` - Public comment added |
| 26 | - - `{:internal_note_created, comment, ticket}` - Internal note added (staff only) |
| 27 | - |
| 28 | - ## Usage Examples |
| 29 | - |
| 30 | - # Subscribe to all ticket events (admin view) |
| 31 | - PhoenixKit.Modules.CustomerService.Events.subscribe_to_all() |
| 32 | - |
| 33 | - # Subscribe to user's tickets |
| 34 | - PhoenixKit.Modules.CustomerService.Events.subscribe_to_user_tickets(user_uuid) |
| 35 | - |
| 36 | - # Subscribe to specific ticket (detail view) |
| 37 | - PhoenixKit.Modules.CustomerService.Events.subscribe_to_ticket(ticket_uuid) |
| 38 | - |
| 39 | - # Handle in LiveView |
| 40 | - def handle_info({:ticket_created, ticket}, socket) do |
| 41 | - # Update UI |
| 42 | - {:noreply, socket} |
| 43 | - end |
| 44 | - """ |
| 45 | - |
| 46 | - alias PhoenixKit.PubSub.Manager |
| 47 | - |
| 48 | - @all_topic "customer_service:all" |
| 49 | - |
| 50 | - # ============================================================================ |
| 51 | - # TOPIC BUILDERS |
| 52 | - # ============================================================================ |
| 53 | - |
| 54 | - @doc """ |
| 55 | - Returns the PubSub topic for a specific user's tickets. |
| 56 | - """ |
| 57 | - def user_topic(user_uuid) when is_binary(user_uuid) do |
| 58 | - "customer_service:user:#{user_uuid}" |
| 59 | - end |
| 60 | - |
| 61 | - @doc """ |
| 62 | - Returns the PubSub topic for a specific ticket. |
| 63 | - """ |
| 64 | - def ticket_topic(ticket_uuid) when is_binary(ticket_uuid) do |
| 65 | - "customer_service:#{ticket_uuid}" |
| 66 | - end |
| 67 | - |
| 68 | - # ============================================================================ |
| 69 | - # SUBSCRIPTION FUNCTIONS |
| 70 | - # ============================================================================ |
| 71 | - |
| 72 | - @doc """ |
| 73 | - Subscribes to all ticket events (for admin views). |
| 74 | - """ |
| 75 | - def subscribe_to_all do |
| 76 | - Manager.subscribe(@all_topic) |
| 77 | - end |
| 78 | - |
| 79 | - @doc """ |
| 80 | - Alias for subscribe_to_all/0 for consistency with naming convention. |
| 81 | - Subscribes to all ticket events (for admin views). |
| 82 | - """ |
| 83 | - def subscribe_tickets, do: subscribe_to_all() |
| 84 | - |
| 85 | - @doc """ |
| 86 | - Subscribes to ticket events for a specific user. |
| 87 | - """ |
| 88 | - def subscribe_to_user_tickets(user_uuid) when is_binary(user_uuid) do |
| 89 | - Manager.subscribe(user_topic(user_uuid)) |
| 90 | - end |
| 91 | - |
| 92 | - @doc """ |
| 93 | - Subscribes to events for a specific ticket (for detail views). |
| 94 | - """ |
| 95 | - def subscribe_to_ticket(ticket_uuid) when is_binary(ticket_uuid) do |
| 96 | - Manager.subscribe(ticket_topic(ticket_uuid)) |
| 97 | - end |
| 98 | - |
| 99 | - @doc """ |
| 100 | - Unsubscribes from all ticket events. |
| 101 | - """ |
| 102 | - def unsubscribe_from_all do |
| 103 | - Manager.unsubscribe(@all_topic) |
| 104 | - end |
| 105 | - |
| 106 | - @doc """ |
| 107 | - Unsubscribes from a specific user's ticket events. |
| 108 | - """ |
| 109 | - def unsubscribe_from_user_tickets(user_uuid) when is_binary(user_uuid) do |
| 110 | - Manager.unsubscribe(user_topic(user_uuid)) |
| 111 | - end |
| 112 | - |
| 113 | - @doc """ |
| 114 | - Unsubscribes from a specific ticket's events. |
| 115 | - """ |
| 116 | - def unsubscribe_from_ticket(ticket_uuid) when is_binary(ticket_uuid) do |
| 117 | - Manager.unsubscribe(ticket_topic(ticket_uuid)) |
| 118 | - end |
| 119 | - |
| 120 | - # ============================================================================ |
| 121 | - # TICKET BROADCASTS |
| 122 | - # ============================================================================ |
| 123 | - |
| 124 | - @doc """ |
| 125 | - Broadcasts ticket created event. |
| 126 | - """ |
| 127 | - def broadcast_ticket_created(ticket) do |
| 128 | - broadcast(@all_topic, {:ticket_created, ticket}) |
| 129 | - broadcast(user_topic(ticket.user_uuid), {:ticket_created, ticket}) |
| 130 | - broadcast(ticket_topic(ticket.uuid), {:ticket_created, ticket}) |
| 131 | - end |
| 132 | - |
| 133 | - @doc """ |
| 134 | - Broadcasts ticket updated event. |
| 135 | - """ |
| 136 | - def broadcast_ticket_updated(ticket) do |
| 137 | - broadcast(@all_topic, {:ticket_updated, ticket}) |
| 138 | - broadcast(user_topic(ticket.user_uuid), {:ticket_updated, ticket}) |
| 139 | - broadcast(ticket_topic(ticket.uuid), {:ticket_updated, ticket}) |
| 140 | - end |
| 141 | - |
| 142 | - @doc """ |
| 143 | - Broadcasts ticket status changed event. |
| 144 | - """ |
| 145 | - def broadcast_ticket_status_changed(ticket, old_status, new_status) do |
| 146 | - message = {:ticket_status_changed, ticket, old_status, new_status} |
| 147 | - broadcast(@all_topic, message) |
| 148 | - broadcast(user_topic(ticket.user_uuid), message) |
| 149 | - broadcast(ticket_topic(ticket.uuid), message) |
| 150 | - end |
| 151 | - |
| 152 | - @doc """ |
| 153 | - Broadcasts ticket assigned event. |
| 154 | - """ |
| 155 | - def broadcast_ticket_assigned(ticket, old_assignee_uuid, new_assignee_uuid) do |
| 156 | - message = {:ticket_assigned, ticket, old_assignee_uuid, new_assignee_uuid} |
| 157 | - broadcast(@all_topic, message) |
| 158 | - broadcast(user_topic(ticket.user_uuid), message) |
| 159 | - broadcast(ticket_topic(ticket.uuid), message) |
| 160 | - |
| 161 | - # Also broadcast to the new assignee's topic if assigned |
| 162 | - if new_assignee_uuid do |
| 163 | - broadcast(user_topic(new_assignee_uuid), message) |
| 164 | - end |
| 165 | - end |
| 166 | - |
| 167 | - @doc """ |
| 168 | - Broadcasts ticket priority changed event. |
| 169 | - """ |
| 170 | - def broadcast_ticket_priority_changed(ticket, old_priority, new_priority) do |
| 171 | - message = {:ticket_priority_changed, ticket, old_priority, new_priority} |
| 172 | - broadcast(@all_topic, message) |
| 173 | - broadcast(user_topic(ticket.user_uuid), message) |
| 174 | - broadcast(ticket_topic(ticket.uuid), message) |
| 175 | - end |
| 176 | - |
| 177 | - @doc """ |
| 178 | - Broadcasts tickets bulk updated event. |
| 179 | - """ |
| 180 | - def broadcast_tickets_bulk_updated(tickets, changes) do |
| 181 | - broadcast(@all_topic, {:tickets_bulk_updated, tickets, changes}) |
| 182 | - |
| 183 | - # Also broadcast to each affected user's topic |
| 184 | - tickets |
| 185 | - |> Enum.map(& &1.user_uuid) |
| 186 | - |> Enum.uniq() |
| 187 | - |> Enum.each(fn user_uuid -> |
| 188 | - broadcast(user_topic(user_uuid), {:tickets_bulk_updated, tickets, changes}) |
| 189 | - end) |
| 190 | - end |
| 191 | - |
| 192 | - # ============================================================================ |
| 193 | - # COMMENT BROADCASTS |
| 194 | - # ============================================================================ |
| 195 | - |
| 196 | - @doc """ |
| 197 | - Broadcasts public comment created event. |
| 198 | - """ |
| 199 | - def broadcast_comment_created(comment, ticket) do |
| 200 | - message = {:comment_created, comment, ticket} |
| 201 | - broadcast(@all_topic, message) |
| 202 | - broadcast(user_topic(ticket.user_uuid), message) |
| 203 | - broadcast(ticket_topic(ticket.uuid), message) |
| 204 | - end |
| 205 | - |
| 206 | - @doc """ |
| 207 | - Broadcasts internal note created event (staff only). |
| 208 | - """ |
| 209 | - def broadcast_internal_note_created(comment, ticket) do |
| 210 | - message = {:internal_note_created, comment, ticket} |
| 211 | - # Internal notes only broadcast to admin topic and ticket topic |
| 212 | - # (not to user's personal topic since they shouldn't see internal notes) |
| 213 | - broadcast(@all_topic, message) |
| 214 | - broadcast(ticket_topic(ticket.uuid), message) |
| 215 | - end |
| 216 | - |
| 217 | - # ============================================================================ |
| 218 | - # PRIVATE HELPERS |
| 219 | - # ============================================================================ |
| 220 | - |
| 221 | - defp broadcast(topic, message) do |
| 222 | - Manager.broadcast(topic, message) |
| 223 | - end |
| 224 | - end |
| @@ -1,266 +0,0 @@ | |
| 1 | - defmodule PhoenixKit.Modules.CustomerService.Ticket do |
| 2 | - @moduledoc """ |
| 3 | - Schema for support tickets. |
| 4 | - |
| 5 | - Represents a customer support request with status workflow, assignment, |
| 6 | - and comment threads including internal notes. |
| 7 | - |
| 8 | - ## Status Flow |
| 9 | - |
| 10 | - - `open` - New ticket, awaiting assignment or response |
| 11 | - - `in_progress` - Being worked on by support staff |
| 12 | - - `resolved` - Issue resolved, awaiting confirmation |
| 13 | - - `closed` - Ticket closed (resolved or abandoned) |
| 14 | - |
| 15 | - ## Transitions |
| 16 | - |
| 17 | - - open -> in_progress (when assigned or work begins) |
| 18 | - - open -> resolved (direct resolution) |
| 19 | - - open -> closed (close without resolution) |
| 20 | - - in_progress -> resolved |
| 21 | - - in_progress -> open (return/unassign) |
| 22 | - - in_progress -> closed |
| 23 | - - resolved -> closed (auto or manual) |
| 24 | - - resolved -> open (reopen by customer) |
| 25 | - - closed -> open (reopen, if allowed) |
| 26 | - |
| 27 | - ## Fields |
| 28 | - |
| 29 | - - `user_uuid` - Customer who created the ticket |
| 30 | - - `assigned_to_uuid` - Support staff handling the ticket |
| 31 | - - `title` - Brief description of the issue |
| 32 | - - `description` - Full details of the issue |
| 33 | - - `status` - open/in_progress/resolved/closed |
| 34 | - - `slug` - URL-friendly identifier |
| 35 | - - `comment_count` - Denormalized counter |
| 36 | - - `metadata` - Flexible JSONB for future extensions |
| 37 | - - `resolved_at` - When ticket was resolved |
| 38 | - - `closed_at` - When ticket was closed |
| 39 | - |
| 40 | - ## Examples |
| 41 | - |
| 42 | - # New ticket |
| 43 | - %Ticket{ |
| 44 | - uuid: "018e3c4a-9f6b-7890-abcd-ef1234567890", |
| 45 | - user_uuid: "018e3c4a-1111-7890-abcd-ef1234567890", |
| 46 | - assigned_to_uuid: nil, |
| 47 | - title: "Cannot login to my account", |
| 48 | - description: "I get an error when trying to login...", |
| 49 | - status: "open", |
| 50 | - slug: "cannot-login-to-my-account", |
| 51 | - comment_count: 0 |
| 52 | - } |
| 53 | - |
| 54 | - # Ticket being worked on |
| 55 | - %Ticket{ |
| 56 | - user_uuid: "018e3c4a-1111-7890-abcd-ef1234567890", |
| 57 | - assigned_to_uuid: "018e3c4a-2222-7890-abcd-ef1234567890", |
| 58 | - title: "Payment failed", |
| 59 | - description: "...", |
| 60 | - status: "in_progress", |
| 61 | - comment_count: 3 |
| 62 | - } |
| 63 | - |
| 64 | - # Resolved ticket |
| 65 | - %Ticket{ |
| 66 | - user_uuid: "018e3c4a-1111-7890-abcd-ef1234567890", |
| 67 | - assigned_to_uuid: "018e3c4a-2222-7890-abcd-ef1234567890", |
| 68 | - status: "resolved", |
| 69 | - resolved_at: ~U[2025-01-15 14:30:00Z] |
| 70 | - } |
| 71 | - """ |
| 72 | - use Ecto.Schema |
| 73 | - import Ecto.Changeset |
| 74 | - |
| 75 | - @primary_key {:uuid, UUIDv7, autogenerate: true} |
| 76 | - @foreign_key_type UUIDv7 |
| 77 | - |
| 78 | - @statuses ["open", "in_progress", "resolved", "closed"] |
| 79 | - |
| 80 | - @type t :: %__MODULE__{ |
| 81 | - uuid: UUIDv7.t() | nil, |
| 82 | - user_uuid: UUIDv7.t(), |
| 83 | - assigned_to_uuid: UUIDv7.t() | nil, |
| 84 | - title: String.t(), |
| 85 | - description: String.t(), |
| 86 | - status: String.t(), |
| 87 | - slug: String.t(), |
| 88 | - comment_count: integer(), |
| 89 | - metadata: map(), |
| 90 | - resolved_at: DateTime.t() | nil, |
| 91 | - closed_at: DateTime.t() | nil, |
| 92 | - user: PhoenixKit.Users.Auth.User.t() | Ecto.Association.NotLoaded.t(), |
| 93 | - assigned_to: PhoenixKit.Users.Auth.User.t() | Ecto.Association.NotLoaded.t() | nil, |
| 94 | - comments: |
| 95 | - [PhoenixKit.Modules.CustomerService.TicketComment.t()] |
| 96 | - | Ecto.Association.NotLoaded.t(), |
| 97 | - attachments: |
| 98 | - [PhoenixKit.Modules.CustomerService.TicketAttachment.t()] |
| 99 | - | Ecto.Association.NotLoaded.t(), |
| 100 | - status_history: |
| 101 | - [PhoenixKit.Modules.CustomerService.TicketStatusHistory.t()] |
| 102 | - | Ecto.Association.NotLoaded.t(), |
| 103 | - inserted_at: DateTime.t() | nil, |
| 104 | - updated_at: DateTime.t() | nil |
| 105 | - } |
| 106 | - |
| 107 | - schema "phoenix_kit_tickets" do |
| 108 | - field :title, :string |
| 109 | - field :description, :string |
| 110 | - field :status, :string, default: "open" |
| 111 | - field :slug, :string |
| 112 | - field :comment_count, :integer, default: 0 |
| 113 | - field :metadata, :map, default: %{} |
| 114 | - field :resolved_at, :utc_datetime |
| 115 | - field :closed_at, :utc_datetime |
| 116 | - |
| 117 | - belongs_to :user, PhoenixKit.Users.Auth.User, |
| 118 | - foreign_key: :user_uuid, |
| 119 | - references: :uuid, |
| 120 | - type: UUIDv7 |
| 121 | - |
| 122 | - belongs_to :assigned_to, PhoenixKit.Users.Auth.User, |
| 123 | - foreign_key: :assigned_to_uuid, |
| 124 | - references: :uuid, |
| 125 | - type: UUIDv7 |
| 126 | - |
| 127 | - has_many :comments, PhoenixKit.Modules.CustomerService.TicketComment, |
| 128 | - foreign_key: :ticket_uuid |
| 129 | - |
| 130 | - has_many :attachments, PhoenixKit.Modules.CustomerService.TicketAttachment, |
| 131 | - foreign_key: :ticket_uuid |
| 132 | - |
| 133 | - has_many :status_history, PhoenixKit.Modules.CustomerService.TicketStatusHistory, |
| 134 | - foreign_key: :ticket_uuid |
| 135 | - |
| 136 | - timestamps(type: :utc_datetime) |
| 137 | - end |
| 138 | - |
| 139 | - @doc """ |
| 140 | - Changeset for creating or updating a ticket. |
| 141 | - |
| 142 | - ## Required Fields |
| 143 | - |
| 144 | - - `user_uuid` - Customer who created the ticket |
| 145 | - - `title` - Brief description |
| 146 | - - `description` - Full details |
| 147 | - - `status` - Must be: "open", "in_progress", "resolved", or "closed" |
| 148 | - |
| 149 | - ## Validation Rules |
| 150 | - |
| 151 | - - Title max 255 characters |
| 152 | - - Status must be valid |
| 153 | - - Slug auto-generated from title if not provided |
| 154 | - """ |
| 155 | - def changeset(ticket, attrs) do |
| 156 | - ticket |
| 157 | - |> cast(attrs, [ |
| 158 | - :user_uuid, |
| 159 | - :assigned_to_uuid, |
| 160 | - :title, |
| 161 | - :description, |
| 162 | - :status, |
| 163 | - :slug, |
| 164 | - :metadata, |
| 165 | - :resolved_at, |
| 166 | - :closed_at |
| 167 | - ]) |
| 168 | - |> validate_required([:user_uuid, :title, :description, :status]) |
| 169 | - |> validate_inclusion(:status, @statuses) |
| 170 | - |> validate_length(:title, max: 255) |
| 171 | - |> maybe_generate_slug() |
| 172 | - |> foreign_key_constraint(:user_uuid) |
| 173 | - |> foreign_key_constraint(:assigned_to_uuid) |
| 174 | - |> unique_constraint(:slug) |
| 175 | - end |
| 176 | - |
| 177 | - @doc """ |
| 178 | - Returns list of valid statuses. |
| 179 | - """ |
| 180 | - def statuses, do: @statuses |
| 181 | - |
| 182 | - @doc """ |
| 183 | - Check if ticket is open. |
| 184 | - """ |
| 185 | - def open?(%__MODULE__{status: "open"}), do: true |
| 186 | - def open?(_), do: false |
| 187 | - |
| 188 | - @doc """ |
| 189 | - Check if ticket is in progress. |
| 190 | - """ |
| 191 | - def in_progress?(%__MODULE__{status: "in_progress"}), do: true |
| 192 | - def in_progress?(_), do: false |
| 193 | - |
| 194 | - @doc """ |
| 195 | - Check if ticket is resolved. |
| 196 | - """ |
| 197 | - def resolved?(%__MODULE__{status: "resolved"}), do: true |
| 198 | - def resolved?(_), do: false |
| 199 | - |
| 200 | - @doc """ |
| 201 | - Check if ticket is closed. |
| 202 | - """ |
| 203 | - def closed?(%__MODULE__{status: "closed"}), do: true |
| 204 | - def closed?(_), do: false |
| 205 | - |
| 206 | - @doc """ |
| 207 | - Check if ticket is assigned to someone. |
| 208 | - """ |
| 209 | - def assigned?(%__MODULE__{assigned_to_uuid: nil}), do: false |
| 210 | - def assigned?(%__MODULE__{}), do: true |
| 211 | - |
| 212 | - @doc """ |
| 213 | - Check if ticket can receive comments (not closed). |
| 214 | - """ |
| 215 | - def can_comment?(%__MODULE__{status: "closed"}), do: false |
| 216 | - def can_comment?(%__MODULE__{}), do: true |
| 217 | - |
| 218 | - @doc """ |
| 219 | - Valid transitions from current status. |
| 220 | - """ |
| 221 | - def valid_transitions("open"), do: ["in_progress", "resolved", "closed"] |
| 222 | - def valid_transitions("in_progress"), do: ["open", "resolved", "closed"] |
| 223 | - def valid_transitions("resolved"), do: ["open", "closed"] |
| 224 | - def valid_transitions("closed"), do: ["open"] |
| 225 | - def valid_transitions(_), do: [] |
| 226 | - |
| 227 | - @doc """ |
| 228 | - Check if transition from current to new status is valid. |
| 229 | - """ |
| 230 | - def valid_transition?(current_status, new_status) do |
| 231 | - new_status in valid_transitions(current_status) |
| 232 | - end |
| 233 | - |
| 234 | - # Private Functions |
| 235 | - |
| 236 | - defp maybe_generate_slug(changeset) do |
| 237 | - case get_change(changeset, :slug) do |
| 238 | - nil -> |
| 239 | - title = get_field(changeset, :title) |
| 240 | - |
| 241 | - if title do |
| 242 | - slug = slugify(title) |
| 243 | - put_change(changeset, :slug, slug) |
| 244 | - else |
| 245 | - changeset |
| 246 | - end |
| 247 | - |
| 248 | - _slug -> |
| 249 | - changeset |
| 250 | - end |
| 251 | - end |
| 252 | - |
| 253 | - defp slugify(title) do |
| 254 | - timestamp = System.system_time(:millisecond) |> Integer.to_string() |> String.slice(-6, 6) |
| 255 | - |
| 256 | - base_slug = |
| 257 | - title |
| 258 | - |> String.downcase() |
| 259 | - |> String.replace(~r/[^\w\s-]/, "") |
| 260 | - |> String.replace(~r/\s+/, "-") |
| 261 | - |> String.trim("-") |
| 262 | - |> String.slice(0, 50) |
| 263 | - |
| 264 | - "#{base_slug}-#{timestamp}" |
| 265 | - end |
| 266 | - end |
Loading more files…