krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

main: Hutch/Views/Tickets/TicketDetailViewModel.swift · raw

  1import Foundation
  2
  3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
  4
  5private struct TicketDetailResponse: Decodable, Sendable {
  6    let tracker: TrackerTicketWrapper
  7}
  8
  9private struct TrackerTicketWrapper: Decodable, Sendable {
 10    let ticket: TicketDetailPayload
 11}
 12
 13private struct TicketDetailPayload: Decodable, Sendable {
 14    let id: Int
 15    let created: Date
 16    let updated: Date
 17    let title: String
 18    let description: String?
 19    let status: TicketStatus
 20    let resolution: TicketResolution?
 21    let authenticity: Authenticity
 22    /// Null when the authenticated user is not subscribed to this ticket.
 23    let subscription: SubscriptionIdPayload?
 24    let submitter: Entity
 25    let assignees: [Entity]
 26    let labels: [TicketLabel]
 27    let events: EventsPage
 28}
 29
 30struct SubscriptionIdPayload: Decodable, Sendable {
 31    let id: Int
 32}
 33
 34private struct EventsPage: Decodable, Sendable {
 35    let results: [TicketEvent]
 36    let cursor: String?
 37}
 38
 39private struct SubmitCommentResponse: Decodable, Sendable {
 40    let submitComment: SubmittedEvent
 41}
 42
 43private struct SubmittedEvent: Decodable, Sendable {
 44    let id: Int
 45    let created: Date
 46    let changes: [EventChange]
 47}
 48
 49private struct MutationEventResponse: Decodable, Sendable {
 50    let id: Int
 51}
 52
 53private struct UpdateStatusResponse: Decodable, Sendable {
 54    let updateTicketStatus: UpdatedStatusEvent
 55}
 56
 57private struct UpdatedStatusEvent: Decodable, Sendable {
 58    let eventType: String
 59}
 60
 61private struct TicketSubscriptionResponse: Decodable, Sendable {
 62    let subscription: SubscriptionIdPayload
 63}
 64
 65private struct UpdateTicketResponse: Decodable, Sendable {
 66    let updateTicket: TicketIdPayload
 67}
 68
 69private struct DeleteTicketResponse: Decodable, Sendable {
 70    let deleteTicket: TicketIdPayload
 71}
 72
 73private struct TicketIdPayload: Decodable, Sendable {
 74    let id: Int
 75}
 76
 77private struct AssignUserResponse: Decodable, Sendable {
 78    let assignUser: MutationEventResponse
 79}
 80
 81private struct UnassignUserResponse: Decodable, Sendable {
 82    let unassignUser: MutationEventResponse
 83}
 84
 85private struct LabelTicketResponse: Decodable, Sendable {
 86    let labelTicket: MutationEventResponse
 87}
 88
 89private struct UnlabelTicketResponse: Decodable, Sendable {
 90    let unlabelTicket: MutationEventResponse
 91}
 92
 93private struct UserLookupResponse: Decodable, Sendable {
 94    let user: UserIdPayload
 95}
 96
 97private struct UserIdPayload: Decodable, Sendable {
 98    let id: Int
 99}
100
101private struct CreateLabelResponse: Decodable, Sendable {
102    let createLabel: TicketLabel
103}
104
105private struct TrackerLabelsResponse: Decodable, Sendable {
106    let tracker: TrackerLabelsWrapper
107}
108
109private struct TrackerLabelsWrapper: Decodable, Sendable {
110    let labels: LabelsPage
111}
112
113private struct LabelsPage: Decodable, Sendable {
114    let results: [TicketLabel]
115}
116
117// MARK: - View Model
118
119@Observable
120@MainActor
121final class TicketDetailViewModel {
122    private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String {
123        APICacheKeys.ticketDetail(owner: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
124    }
125
126    let ownerUsername: String
127    let trackerName: String
128    let trackerId: Int
129    let trackerRid: String
130    let ticketId: Int
131
132    private(set) var ticket: TicketDetail?
133    private(set) var events: [TicketEvent] = []
134    private(set) var isLoading = false
135    private(set) var isSubmitting = false
136    private(set) var isPerformingAction = false
137    /// Whether the authenticated user receives email for this ticket. Mirrors
138    /// `Ticket.subscription`, which is null when not subscribed.
139    private(set) var isSubscribed = false
140    private(set) var trackerLabels: [TicketLabel] = []
141    private(set) var rawTicketResponse: String?
142    private(set) var cacheMetadata: CacheEntryMetadata?
143    private(set) var isRefreshingCachedData = false
144    var commentText = ""
145    var error: String?
146
147    private let client: SRHTClient
148
149    private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
150        if lhs.created == rhs.created {
151            return lhs.id < rhs.id
152        }
153        return lhs.created < rhs.created
154    }
155
156    static func statusUpdateInput(
157        status: TicketStatus,
158        resolution: TicketResolution?
159    ) -> [String: any Sendable] {
160        var input: [String: any Sendable] = [
161            "status": status.rawValue
162        ]
163        if status == .resolved, let resolution {
164            input["resolution"] = resolution.rawValue
165        }
166        return input
167    }
168
169    /// Builds an `UpdateTicketInput` carrying only the fields that changed, so an
170    /// edit never overwrites a field the user did not touch.
171    static func ticketUpdateInput(
172        subject: String,
173        body: String,
174        currentSubject: String,
175        currentBody: String?
176    ) -> [String: any Sendable] {
177        var input: [String: any Sendable] = [:]
178        let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
179        let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
180
181        if trimmedSubject != currentSubject {
182            input["subject"] = trimmedSubject
183        }
184
185        if trimmedBody != (currentBody ?? "") {
186            if trimmedBody.isEmpty {
187                // A nil subscript assignment would drop the key and leave the old
188                // body in place instead of clearing it.
189                input.updateValue(Optional<String>.none as any Sendable, forKey: "body")
190            } else {
191                input["body"] = trimmedBody
192            }
193        }
194
195        return input
196    }
197
198    init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
199        self.ownerUsername = ownerUsername
200        self.trackerName = trackerName
201        self.trackerId = trackerId
202        self.trackerRid = trackerRid
203        self.ticketId = ticketId
204        self.client = client
205    }
206
207    // MARK: - Queries
208
209    private static let detailQuery = """
210    query ticket($rid: ID!, $ticketId: Int!) {
211        tracker(rid: $rid) {
212            ticket(id: $ticketId) {
213                id
214                created
215                updated
216                title: subject
217                description: body
218                status
219                resolution
220                authenticity
221                subscription { id }
222                submitter { canonicalName }
223                assignees { canonicalName }
224                labels { id name backgroundColor foregroundColor }
225                events {
226                    results {
227                        id
228                        created
229                        changes {
230                            eventType
231                            ... on Comment {
232                                author { canonicalName }
233                                text
234                                authenticity
235                            }
236                            ... on StatusChange {
237                                oldStatus
238                                newStatus
239                            }
240                            ... on LabelUpdate {
241                                labeler { canonicalName }
242                                label { name }
243                            }
244                            ... on Assignment {
245                                assigner { canonicalName }
246                                assignee { canonicalName }
247                            }
248                            ... on TicketMention {
249                                mentioned { id }
250                            }
251                            ... on UserMention {
252                                mentioned { canonicalName }
253                            }
254                            ... on Created {
255                                author { canonicalName }
256                            }
257                        }
258                    }
259                    cursor
260                }
261            }
262        }
263    }
264    """
265
266    private static let submitCommentMutation = """
267    mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
268        submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
269            id
270            created
271            changes {
272                eventType
273                ... on Comment {
274                    author { canonicalName }
275                    text
276                    authenticity
277                }
278            }
279        }
280    }
281    """
282
283    private static let updateStatusMutation = """
284    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
285        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
286            eventType: __typename
287        }
288    }
289    """
290
291    private static let updateTicketMutation = """
292    mutation updateTicket($trackerId: Int!, $ticketId: Int!, $input: UpdateTicketInput!) {
293        updateTicket(trackerId: $trackerId, ticketId: $ticketId, input: $input) { id }
294    }
295    """
296
297    private static let deleteTicketMutation = """
298    mutation deleteTicket($trackerId: Int!, $ticketId: Int!) {
299        deleteTicket(trackerId: $trackerId, ticketId: $ticketId) { id }
300    }
301    """
302
303    private static let ticketSubscribeMutation = """
304    mutation ticketSubscribe($trackerId: Int!, $ticketId: Int!) {
305        subscription: ticketSubscribe(trackerId: $trackerId, ticketId: $ticketId) { id }
306    }
307    """
308
309    private static let ticketUnsubscribeMutation = """
310    mutation ticketUnsubscribe($trackerId: Int!, $ticketId: Int!) {
311        subscription: ticketUnsubscribe(trackerId: $trackerId, ticketId: $ticketId) { id }
312    }
313    """
314
315    private static let assignUserMutation = """
316    mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
317        assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
318    }
319    """
320
321    private static let unassignUserMutation = """
322    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
323        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
324    }
325    """
326
327    private static let labelTicketMutation = """
328    mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
329        labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
330    }
331    """
332
333    private static let unlabelTicketMutation = """
334    mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
335        unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
336    }
337    """
338
339    private static let userLookupQuery = """
340    query userLookup($username: String!) {
341        user(username: $username) { id }
342    }
343    """
344
345    private static let trackerLabelsQuery = """
346    query trackerLabels($rid: ID!) {
347        tracker(rid: $rid) {
348            labels {
349                results { id name backgroundColor foregroundColor }
350            }
351        }
352    }
353    """
354
355    private static let createLabelMutation = """
356    mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
357        createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
358            id
359            name
360            backgroundColor
361            foregroundColor
362        }
363    }
364    """
365
366    // MARK: - Public API
367
368    func loadTicket() async {
369        guard !isLoading else { return }
370        isLoading = true
371        error = nil
372        rawTicketResponse = nil
373
374        do {
375            let result = try await client.executeCached(
376                service: .todo,
377                query: Self.detailQuery,
378                variables: [
379                    "rid": trackerRid,
380                    "ticketId": ticketId
381                ],
382                responseType: TicketDetailResponse.self,
383                cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
384                resourceType: .ticketDetail,
385                ttl: APICacheTTLs.ticketDetail,
386                policy: .cacheFirstThenRefresh
387            )
388            apply(result.value, metadata: result.metadata)
389            if result.isFromCache {
390                isLoading = false
391                await refreshTicketInBackground()
392                return
393            }
394        } catch {
395            self.error = error.userFacingMessage
396        }
397
398        isLoading = false
399    }
400
401    func loadTicketWithDebugCapture() async {
402        guard !isLoading else { return }
403        isLoading = true
404        error = nil
405
406        do {
407            let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
408            let result = try await client.executeCached(
409                service: .todo,
410                query: Self.detailQuery,
411                variables: [
412                    "rid": trackerRid,
413                    "ticketId": ticketId
414                ],
415                responseType: TicketDetailResponse.self,
416                cacheKey: cacheKey,
417                resourceType: .ticketDetail,
418                ttl: APICacheTTLs.ticketDetail,
419                policy: .refreshIgnoringCache
420            )
421            rawTicketResponse = await client.cachedPayload(forKey: cacheKey)
422                .flatMap { String(data: $0, encoding: .utf8) }
423            apply(result.value, metadata: result.metadata)
424        } catch {
425            self.error = error.userFacingMessage
426        }
427
428        isLoading = false
429    }
430
431    func submitComment() async {
432        let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
433        guard !text.isEmpty, !isSubmitting else { return }
434        isSubmitting = true
435        error = nil
436
437        do {
438            let input: [String: any Sendable] = ["text": text]
439            let result = try await client.execute(
440                service: .todo,
441                query: Self.submitCommentMutation,
442                variables: [
443                    "trackerId": trackerId,
444                    "ticketId": ticketId,
445                    "input": input
446                ],
447                responseType: SubmitCommentResponse.self
448            )
449            // Append the returned event so the comment shows immediately.
450            let submitted = result.submitComment
451            let event = TicketEvent(
452                id: submitted.id,
453                created: submitted.created,
454                changes: submitted.changes
455            )
456            events.append(event)
457            events.sort(by: Self.timelineOrder)
458            commentText = ""
459            await invalidateAfterMutation()
460        } catch {
461            self.error = error.userFacingMessage
462        }
463
464        isSubmitting = false
465    }
466    
467    func updateComment(commentId: Int, text: String) async {
468        _ = commentId
469        _ = text
470        error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
471    }
472
473    // MARK: - Ticket Actions
474
475    func updateStatus(status: TicketStatus, resolution: TicketResolution? = nil) async {
476        guard !isPerformingAction else { return }
477        isPerformingAction = true
478        error = nil
479
480        do {
481            let input = Self.statusUpdateInput(status: status, resolution: resolution)
482            _ = try await client.execute(
483                service: .todo,
484                query: Self.updateStatusMutation,
485                variables: [
486                    "trackerId": trackerId,
487                    "ticketId": ticketId,
488                    "input": input
489                ],
490                responseType: UpdateStatusResponse.self
491            )
492            await invalidateAfterMutation()
493            // Re-fetch the ticket to get updated status/resolution
494            await reloadTicketPreservingDebugState()
495        } catch {
496            self.error = error.userFacingMessage
497        }
498
499        isPerformingAction = false
500    }
501
502    /// Edits the ticket's subject and body. Returns true when the edit was sent,
503    /// including the no-op case where nothing changed.
504    @discardableResult
505    func updateTicket(subject: String, body: String) async -> Bool {
506        guard !isPerformingAction, let ticket else { return false }
507
508        let input = Self.ticketUpdateInput(
509            subject: subject,
510            body: body,
511            currentSubject: ticket.title,
512            currentBody: ticket.description
513        )
514        guard !input.isEmpty else { return true }
515
516        isPerformingAction = true
517        error = nil
518        defer { isPerformingAction = false }
519
520        do {
521            _ = try await client.execute(
522                service: .todo,
523                query: Self.updateTicketMutation,
524                variables: [
525                    "trackerId": trackerId,
526                    "ticketId": ticketId,
527                    "input": input
528                ],
529                responseType: UpdateTicketResponse.self
530            )
531            await invalidateAfterMutation()
532            await reloadTicketPreservingDebugState()
533            return true
534        } catch {
535            self.error = error.userFacingMessage
536            return false
537        }
538    }
539
540    /// Subscribes to or unsubscribes from email notifications for this ticket.
541    func toggleSubscription() async {
542        guard !isPerformingAction else { return }
543        isPerformingAction = true
544        error = nil
545        defer { isPerformingAction = false }
546
547        let wasSubscribed = isSubscribed
548        // Reflect the change immediately; the catch below puts it back if the
549        // mutation fails, so the control never lies about server state.
550        isSubscribed.toggle()
551
552        do {
553            _ = try await client.execute(
554                service: .todo,
555                query: wasSubscribed ? Self.ticketUnsubscribeMutation : Self.ticketSubscribeMutation,
556                variables: [
557                    "trackerId": trackerId,
558                    "ticketId": ticketId
559                ],
560                responseType: TicketSubscriptionResponse.self
561            )
562            await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
563        } catch {
564            isSubscribed = wasSubscribed
565            self.error = error.userFacingMessage
566        }
567    }
568
569    /// Deletes the ticket. Returns true on success so the caller can pop the view.
570    @discardableResult
571    func deleteTicket() async -> Bool {
572        guard !isPerformingAction else { return false }
573        isPerformingAction = true
574        error = nil
575        defer { isPerformingAction = false }
576
577        do {
578            _ = try await client.execute(
579                service: .todo,
580                query: Self.deleteTicketMutation,
581                variables: [
582                    "trackerId": trackerId,
583                    "ticketId": ticketId
584                ],
585                responseType: DeleteTicketResponse.self
586            )
587            await invalidateAfterMutation()
588            return true
589        } catch {
590            self.error = error.userFacingMessage
591            return false
592        }
593    }
594
595    func assignUser(username: String) async {
596        guard !isPerformingAction else { return }
597        isPerformingAction = true
598        error = nil
599
600        do {
601            // Resolve username to user ID
602            let userResult = try await client.execute(
603                service: .todo,
604                query: Self.userLookupQuery,
605                variables: ["username": username],
606                responseType: UserLookupResponse.self
607            )
608            let userId = userResult.user.id
609
610            _ = try await client.execute(
611                service: .todo,
612                query: Self.assignUserMutation,
613                variables: [
614                    "trackerId": trackerId,
615                    "ticketId": ticketId,
616                    "userId": userId
617                ],
618                responseType: AssignUserResponse.self
619            )
620            await invalidateAfterMutation()
621            // Reload to reflect the change
622            await reloadTicketPreservingDebugState()
623        } catch {
624            self.error = error.userFacingMessage
625        }
626
627        isPerformingAction = false
628    }
629
630    func assignToCurrentUser(_ user: User) async {
631        guard !isPerformingAction, let currentTicket = ticket else { return }
632
633        let currentAssignees = currentTicket.assignees
634        let currentEntity = Entity(canonicalName: user.canonicalName)
635        guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
636            return
637        }
638
639        isPerformingAction = true
640        error = nil
641
642        ticket = TicketDetail(
643            id: currentTicket.id,
644            created: currentTicket.created,
645            updated: currentTicket.updated,
646            title: currentTicket.title,
647            description: currentTicket.description,
648            status: currentTicket.status,
649            resolution: currentTicket.resolution,
650            authenticity: currentTicket.authenticity,
651            submitter: currentTicket.submitter,
652            assignees: currentAssignees + [currentEntity],
653            labels: currentTicket.labels
654        )
655
656        do {
657            _ = try await client.execute(
658                service: .todo,
659                query: Self.assignUserMutation,
660                variables: [
661                    "trackerId": trackerId,
662                    "ticketId": ticketId,
663                    "userId": user.id
664                ],
665                responseType: AssignUserResponse.self
666            )
667            await invalidateAfterMutation()
668            await reloadTicketPreservingDebugState()
669        } catch {
670            ticket = TicketDetail(
671                id: currentTicket.id,
672                created: currentTicket.created,
673                updated: currentTicket.updated,
674                title: currentTicket.title,
675                description: currentTicket.description,
676                status: currentTicket.status,
677                resolution: currentTicket.resolution,
678                authenticity: currentTicket.authenticity,
679                submitter: currentTicket.submitter,
680                assignees: currentAssignees,
681                labels: currentTicket.labels
682            )
683            self.error = error.userFacingMessage
684        }
685
686        isPerformingAction = false
687    }
688
689    func unassignUser(username: String) async {
690        guard !isPerformingAction else { return }
691        isPerformingAction = true
692        error = nil
693
694        do {
695            // Resolve username to user ID
696            let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
697            let userResult = try await client.execute(
698                service: .todo,
699                query: Self.userLookupQuery,
700                variables: ["username": stripped],
701                responseType: UserLookupResponse.self
702            )
703            let userId = userResult.user.id
704
705            _ = try await client.execute(
706                service: .todo,
707                query: Self.unassignUserMutation,
708                variables: [
709                    "trackerId": trackerId,
710                    "ticketId": ticketId,
711                    "userId": userId
712                ],
713                responseType: UnassignUserResponse.self
714            )
715            await invalidateAfterMutation()
716            // Reload to reflect the change
717            await reloadTicketPreservingDebugState()
718        } catch {
719            self.error = error.userFacingMessage
720        }
721
722        isPerformingAction = false
723    }
724
725    func labelTicket(labelId: Int) async {
726        guard !isPerformingAction else { return }
727        isPerformingAction = true
728        error = nil
729
730        do {
731            _ = try await client.execute(
732                service: .todo,
733                query: Self.labelTicketMutation,
734                variables: [
735                    "trackerId": trackerId,
736                    "ticketId": ticketId,
737                    "labelId": labelId
738                ],
739                responseType: LabelTicketResponse.self
740            )
741            await invalidateAfterMutation()
742            await reloadTicketPreservingDebugState()
743        } catch {
744            self.error = error.userFacingMessage
745        }
746
747        isPerformingAction = false
748    }
749
750    func unlabelTicket(labelId: Int) async {
751        guard !isPerformingAction else { return }
752        isPerformingAction = true
753        error = nil
754
755        do {
756            _ = try await client.execute(
757                service: .todo,
758                query: Self.unlabelTicketMutation,
759                variables: [
760                    "trackerId": trackerId,
761                    "ticketId": ticketId,
762                    "labelId": labelId
763                ],
764                responseType: UnlabelTicketResponse.self
765            )
766            await invalidateAfterMutation()
767            await reloadTicketPreservingDebugState()
768        } catch {
769            self.error = error.userFacingMessage
770        }
771
772        isPerformingAction = false
773    }
774
775    func loadTrackerLabels() async {
776        do {
777            let result = try await client.execute(
778                service: .todo,
779                query: Self.trackerLabelsQuery,
780                variables: ["rid": trackerRid],
781                responseType: TrackerLabelsResponse.self
782            )
783            trackerLabels = result.tracker.labels.results
784        } catch {
785            self.error = error.userFacingMessage
786        }
787    }
788
789    func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
790        guard !isPerformingAction else { return }
791        isPerformingAction = true
792        error = nil
793
794        do {
795            let result = try await client.execute(
796                service: .todo,
797                query: Self.createLabelMutation,
798                variables: [
799                    "trackerId": trackerId,
800                    "name": name,
801                    "backgroundColor": backgroundColor,
802                    "foregroundColor": foregroundColor
803                ],
804                responseType: CreateLabelResponse.self
805            )
806            trackerLabels.append(result.createLabel)
807        } catch {
808            self.error = error.userFacingMessage
809        }
810
811        isPerformingAction = false
812    }
813
814    private func reloadTicketPreservingDebugState() async {
815        if rawTicketResponse != nil {
816            await loadTicketWithDebugCapture()
817        } else {
818            await loadTicket()
819        }
820    }
821
822    private func refreshTicketInBackground() async {
823        guard !isRefreshingCachedData else { return }
824        isRefreshingCachedData = true
825        defer { isRefreshingCachedData = false }
826
827        do {
828            let result = try await client.executeCached(
829                service: .todo,
830                query: Self.detailQuery,
831                variables: [
832                    "rid": trackerRid,
833                    "ticketId": ticketId
834                ],
835                responseType: TicketDetailResponse.self,
836                cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
837                resourceType: .ticketDetail,
838                ttl: APICacheTTLs.ticketDetail,
839                policy: .refreshIgnoringCache
840            )
841            apply(result.value, metadata: result.metadata)
842        } catch {
843            if ticket == nil {
844                self.error = error.userFacingMessage
845            }
846        }
847    }
848
849    private func apply(_ response: TicketDetailResponse, metadata: CacheEntryMetadata?) {
850        cacheMetadata = metadata
851        let payload = response.tracker.ticket
852        let updatedTicket = TicketDetail(
853            id: payload.id,
854            created: payload.created,
855            updated: payload.updated,
856            title: payload.title,
857            description: payload.description,
858            status: payload.status,
859            resolution: payload.resolution,
860            authenticity: payload.authenticity,
861            submitter: payload.submitter,
862            assignees: payload.assignees,
863            labels: payload.labels
864        )
865        ticket = updatedTicket
866        isSubscribed = payload.subscription != nil
867        let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder)
868        events = updatedEvents
869    }
870
871    private func invalidateAfterMutation() async {
872        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
873        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
874        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
875        await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
876    }
877
878    static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
879        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
880        let userCanonical = normalizedCanonicalName(user.canonicalName)
881        if assigneeCanonical == userCanonical {
882            return true
883        }
884        return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
885    }
886
887    private static func normalizedCanonicalName(_ value: String) -> String {
888        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
889        if trimmed.hasPrefix("~") {
890            return trimmed
891        }
892        return "~\(trimmed)"
893    }
894
895    private static func normalizedUsername(_ value: String) -> String {
896        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
897        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
898    }
899
900}