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

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…