Interface ISupportCaseStore
- Namespace
- Tharga.Team
- Assembly
- Tharga.Team.dll
The persistence port for support cases — what the domain needs from a store, in the domain's language.
public interface ISupportCaseStore
Remarks
A port, not a repository. Nothing here names or inherits a storage type: no IRepository
base, no driver types, no filter objects. IApiKeyRepository : IRepository is the shape this
deliberately avoids — a port defined in one store's terms means the second adapter has to implement the
first store's idea of persistence.
Every method takes the team. A case id alone never identifies a case, so the tenant boundary is expressed by the port itself rather than trusted to each caller to remember. That is what makes the cross-tenant read hard to write by accident.
Two methods carry a message alongside a state change, and that is the atomicity contract. Raising a case creates a case and its first message; closing one sets a status and records why. An adapter must apply each as a single unit, or a crash leaves a case with no transcript and the model's central promise — a case always has at least one message — becomes untrue.
Methods
AddAssistantGapsAsync(string, string, string[], CancellationToken)
Adds to what the assistant could not do in a case, keeping the newest MaxAssistantGaps.
Task AddAssistantGapsAsync(string teamKey, string caseId, string[] gaps, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringgapsstring[]cancellationTokenCancellationToken
Returns
Remarks
The default keeps nothing, so a store written before this existed loses only that list: the answer, and everything else about the case, is unaffected.
AddBindingAsync(string, string, SupportChannelBinding, CancellationToken)
Records a case's projection onto an external channel.
Task AddBindingAsync(string teamKey, string caseId, SupportChannelBinding binding, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringbindingSupportChannelBindingcancellationTokenCancellationToken
Returns
AddCaseAsync(SupportCase, SupportMessage, CancellationToken)
Creates a case together with its first message, as one unit.
Task AddCaseAsync(SupportCase supportCase, SupportMessage firstMessage, CancellationToken cancellationToken = default)
Parameters
supportCaseSupportCasefirstMessageSupportMessagecancellationTokenCancellationToken
Returns
AppendMessageAsync(string, string, SupportMessage, CancellationToken)
Appends one entry to a case's transcript.
Task AppendMessageAsync(string teamKey, string caseId, SupportMessage message, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringmessageSupportMessagecancellationTokenCancellationToken
Returns
CloseCaseAsync(string, string, DateTime, string, SupportMessage, CancellationToken)
Closes a case and records the closure in its transcript, as one unit.
Task CloseCaseAsync(string teamKey, string caseId, DateTime closedAt, string closedBy, SupportMessage closureMessage, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringclosedAtDateTimeclosedBystringclosureMessageSupportMessagecancellationTokenCancellationToken
Returns
DeleteCasesForTeamAsync(string, CancellationToken)
Destroys every case belonging to a team. Backs the purge cascade.
Task<int> DeleteCasesForTeamAsync(string teamKey, CancellationToken cancellationToken = default)
Parameters
teamKeystringcancellationTokenCancellationToken
Returns
Remarks
Purging a team drops the host's per-team database, which does not reach the toolkit's own shared collections — so without this the cases would outlive the team that owned them.
GetAnsweredCountAsync(string, string, CancellationToken)
How many of this person's own open cases end with an entry that is not theirs, the assistant's conversations aside. Defaults to none, so a store written before this keeps compiling.
Task<int> GetAnsweredCountAsync(string teamKey, string identity, CancellationToken cancellationToken = default)
Parameters
teamKeystringidentitystringcancellationTokenCancellationToken
Returns
GetAwaitingSupportCountAcrossTeamsAsync(CancellationToken)
How many open cases that belong to a team, across every team, are waiting on support.
Task<int> GetAwaitingSupportCountAcrossTeamsAsync(CancellationToken cancellationToken = default)
Parameters
cancellationTokenCancellationToken
Returns
Remarks
The count of what GetCasesAcrossTeamsAsync(string, int, CancellationToken) lists, by the rule GetAwaitingSupportCountAsync(string, CancellationToken) uses per team. Reached only from a caller already checked against AllRead.
Defaults to zero, so a store written before this existed keeps compiling.
GetAwaitingSupportCountAsync(string, CancellationToken)
How many cases in the team are waiting on support — their newest entry came from the person who raised them.
Task<int> GetAwaitingSupportCountAsync(string teamKey, CancellationToken cancellationToken = default)
Parameters
teamKeystringcancellationTokenCancellationToken
Returns
GetAwaitingSupportCountVisibleToTeamAsync(string, string, CancellationToken)
How many of the cases GetCasesVisibleToTeamAsync(string, string, string, int, CancellationToken) returns are waiting on support.
Task<int> GetAwaitingSupportCountVisibleToTeamAsync(string teamKey, string identity, CancellationToken cancellationToken = default)
Parameters
teamKeystringidentitystringcancellationTokenCancellationToken
Returns
Remarks
Defaults to zero, for the same reason the listing defaults to nothing.
GetAwaitingUnassignedCountAsync(CancellationToken)
How many open cases that belong to no team are waiting on support.
Task<int> GetAwaitingUnassignedCountAsync(CancellationToken cancellationToken = default)
Parameters
cancellationTokenCancellationToken
Returns
Remarks
The count of what GetUnassignedCasesAsync(string, int, CancellationToken) lists. Reached only from a caller already checked against Read.
Defaults to zero, so a store written before this existed keeps compiling.
GetCaseAsync(string, string, CancellationToken)
One case, or null if the team has no such case.
Task<SupportCase> GetCaseAsync(string teamKey, string caseId, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringcancellationTokenCancellationToken
Returns
GetCaseByBindingAsync(SupportChannelType, string, CancellationToken)
The case projected onto a given channel identifier, or null if no case is bound to it.
Task<SupportCase> GetCaseByBindingAsync(SupportChannelType channelType, string externalId, CancellationToken cancellationToken = default)
Parameters
channelTypeSupportChannelTypeexternalIdstringcancellationTokenCancellationToken
Returns
Remarks
The only read here that is not scoped by team, and it has to be: an inbound event arrives carrying a channel's identifier and nothing else, so the binding is what resolves the team rather than something the caller supplies. It is reached only from a channel adapter handling a verified event, never from a user-facing path.
GetCaseByIdAsync(string, CancellationToken)
The case with this id whatever team owns it, or null.
Task<SupportCase> GetCaseByIdAsync(string caseId, CancellationToken cancellationToken = default)
Parameters
caseIdstringcancellationTokenCancellationToken
Returns
Remarks
For inbound mail that names a case but not a team, which is what a per-case reply address
(support+{caseId}@…) carries when the sender's client dropped the threading headers. Like
GetCaseByBindingAsync(SupportChannelType, string, CancellationToken) it is not scoped by team and for the same reason: the identifier
is what resolves the team, not the caller. Reached only from a channel adapter, never from a
user-facing path.
Defaults to finding nothing, so a store written before this existed keeps compiling and keeps working — the threading headers are the primary match and this is only the fallback. A store that cannot look up by id alone is answering honestly rather than failing.
GetCasesAcrossTeamsAsync(string, int, CancellationToken)
Cases that belong to a team, across every team, newest first — or an empty page when the store cannot answer.
Task<SupportCasePage> GetCasesAcrossTeamsAsync(string cursor, int pageSize, CancellationToken cancellationToken = default)
Parameters
cursorstringpageSizeintcancellationTokenCancellationToken
Returns
Remarks
Excludes the unassigned queue, and that is the whole distinction. A case with no team is a different population reached by a different grant; folding the two together here would hand the unassigned queue to anyone holding AllRead.
Not team-scoped, because the caller is deliberately not bound to one — reached only from a caller already checked against AllRead.
Defaults to nothing, so a store written before this existed keeps compiling.
GetCasesAsync(string, string, int, CancellationToken)
Every case in a team, newest first.
Task<SupportCasePage> GetCasesAsync(string teamKey, string cursor, int pageSize, CancellationToken cancellationToken = default)
Parameters
teamKeystringcursorstringpageSizeintcancellationTokenCancellationToken
Returns
GetCasesAwaitingSupportSinceAsync(DateTime, int, CancellationToken)
Open cases, across every team, whose newest entry was written by the person who raised them before
waitingSince — the ones nobody has answered in time.
Task<SupportCase[]> GetCasesAwaitingSupportSinceAsync(DateTime waitingSince, int limit, CancellationToken cancellationToken = default)
Parameters
waitingSinceDateTimeCases whose newest entry is older than this are returned.
limitintMost cases to return, so one pass cannot load an unbounded set.
cancellationTokenCancellationTokenAbandons the read.
Returns
- Task<SupportCase[]>
Remarks
For a host escalating unanswered cases, typically from a single-instance worker. The same state GetAwaitingSupportCountAsync(string, CancellationToken) counts per team, asked across every team at once.
Includes unassigned cases. A case with no team is waiting on support just as much as one that has a team.
A case keeps matching on every pass until somebody answers it, so deduplication is the caller's. Key "already escalated" on Id together with MessageCount: the pair names exactly one newest entry, so a case that is answered and then written to again escalates afresh. TryRecordAsync(string, string, CancellationToken) fits as-is.
Newest first — most recent entry on top. Because matching cases stay matching, an arbitrary
order under limit could return the same already-escalated cases on every pass and
never reach the rest. Newest first puts the cases that have only just crossed the window on top.
Not team-scoped, because a sweep has no caller and no team — framework code, like GetCasesForInactivityCloseAsync(DateTime, int, CancellationToken). Never reach it from a user-facing path.
Defaults to nothing, so a store written before this existed keeps compiling and never escalates.
GetCasesByAuthorAsync(string, string, string, int, CancellationToken)
Cases raised by one author, newest first.
Task<SupportCasePage> GetCasesByAuthorAsync(string teamKey, string authorIdentity, string cursor, int pageSize, CancellationToken cancellationToken = default)
Parameters
teamKeystringauthorIdentitystringcursorstringpageSizeintcancellationTokenCancellationToken
Returns
GetCasesForInactivityCloseAsync(DateTime, int, CancellationToken)
Open cases whose newest entry was written by support before
lastActivityBefore — the ones the inactivity sweep may close.
Task<SupportCase[]> GetCasesForInactivityCloseAsync(DateTime lastActivityBefore, int limit, CancellationToken cancellationToken = default)
Parameters
lastActivityBeforeDateTimeCases untouched since this moment are eligible.
limitintMost cases to return, so one sweep cannot load an unbounded set.
cancellationTokenCancellationTokenAbandons the read.
Returns
- Task<SupportCase[]>
Remarks
The store owns the whole predicate, not just the cheap half. "Support wrote the newest entry" needs the last element of an embedded transcript, which is not an indexable filter — but the transcript arrives with the document, so a store can narrow on indexed fields and then check the tail without a second read. Splitting it, so a caller re-reads each candidate to inspect it, would turn one query into one query per case.
A system entry is not support answering. A case whose newest entry is the toolkit's own — a reopen note — must not be returned, or reopening a case would arm the very clock that closes it.
Not team-scoped, because a sweep has no caller and no team. It runs as framework code on nobody's behalf; see also GetCaseByBindingAsync(SupportChannelType, string, CancellationToken).
Defaults to nothing, so a store written before this existed keeps compiling and simply never auto-closes.
GetCasesVisibleToTeamAsync(string, string, string, int, CancellationToken)
A team's cases that its support readers may see under per-case visibility — those shared with the team,
plus identity's own — newest first.
Task<SupportCasePage> GetCasesVisibleToTeamAsync(string teamKey, string identity, string cursor, int pageSize, CancellationToken cancellationToken = default)
Parameters
teamKeystringidentitystringcursorstringpageSizeintcancellationTokenCancellationToken
Returns
Remarks
A case with no visibility recorded is not shared. Defaults to nothing, so a store written before visibility existed shows a team reader nothing rather than everything.
GetCasesWithUnreadAnswerSinceAsync(DateTime, int, CancellationToken)
Open cases, across every team, whose newest entry is an answer written before
answeredBefore that the person who raised the case has not read.
Task<SupportCase[]> GetCasesWithUnreadAnswerSinceAsync(DateTime answeredBefore, int limit, CancellationToken cancellationToken = default)
Parameters
answeredBeforeDateTimeAnswers older than this are returned.
limitintMost cases to return, so one pass cannot load an unbounded set.
cancellationTokenCancellationTokenAbandons the read.
Returns
- Task<SupportCase[]>
Remarks
For a host delivering unread answers by mail, so the customer need not keep the page open. The read state is the same one GetUnreadCountAsync(string, string, CancellationToken) computes per team and per person.
An answer is an entry by a person other than the author, or by an assistant. A System entry — a reopen or assignment note — is never an answer, so it neither matches nor hides an earlier one: a case whose newest entry is the toolkit's is not returned.
Includes unassigned cases, is ordered newest first, and leaves deduplication to the caller, keyed the same way as GetCasesAwaitingSupportSinceAsync(DateTime, int, CancellationToken).
Not team-scoped and defaults to nothing, for the same reasons as GetCasesAwaitingSupportSinceAsync(DateTime, int, CancellationToken).
GetMessagesAsync(string, string, string, int, CancellationToken)
A case's transcript in order, oldest first.
Task<SupportMessagePage> GetMessagesAsync(string teamKey, string caseId, string cursor, int pageSize, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringcursorstringpageSizeintcancellationTokenCancellationToken
Returns
GetOpenCountAsync(string, string, CancellationToken)
How many of this person's own cases are open, the assistant's conversations aside. Defaults to none.
Task<int> GetOpenCountAsync(string teamKey, string identity, CancellationToken cancellationToken = default)
Parameters
teamKeystringidentitystringcancellationTokenCancellationToken
Returns
GetUnassignedCasesAsync(string, int, CancellationToken)
Cases belonging to no team, newest first, or an empty page when the store cannot answer.
Task<SupportCasePage> GetUnassignedCasesAsync(string cursor, int pageSize, CancellationToken cancellationToken = default)
Parameters
cursorstringpageSizeintcancellationTokenCancellationToken
Returns
Remarks
Not team-scoped, because there is no team — the same shape as GetCaseByBindingAsync(SupportChannelType, string, CancellationToken), and reached only from a caller already checked against Read.
Defaults to nothing, so a store written before unassigned cases existed keeps compiling.
GetUnreadByCaseAsync(string, string, CancellationToken)
This person's own cases holding entries they have not read, with how many each.
Task<SupportCaseUnread[]> GetUnreadByCaseAsync(string teamKey, string identity, CancellationToken cancellationToken = default)
Parameters
teamKeystringidentitystringcancellationTokenCancellationToken
Returns
Remarks
Defaults to nothing, so a store written before this existed keeps compiling.
GetUnreadCountAsync(string, string, CancellationToken)
How many of this person's own cases hold entries they have not read.
Task<int> GetUnreadCountAsync(string teamKey, string identity, CancellationToken cancellationToken = default)
Parameters
teamKeystringidentitystringcancellationTokenCancellationToken
Returns
MarkReadAsync(string, string, string, int, CancellationToken)
Records that someone has read a case up to sequence.
Task MarkReadAsync(string teamKey, string caseId, string identity, int sequence, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringidentitystringsequenceintcancellationTokenCancellationToken
Returns
Remarks
Idempotent, and must not grow the document: one entry per person, updated in place. Somebody opening a case fifty times leaves one entry.
ReopenCaseAsync(string, string, SupportMessage, CancellationToken)
Opens a closed case again and records it in the transcript, as one unit.
Task ReopenCaseAsync(string teamKey, string caseId, SupportMessage reopenMessage, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringreopenMessageSupportMessagecancellationTokenCancellationToken
Returns
Remarks
Clears the closure entirely — status, timestamp and actor. A case left carrying who closed it while open would read as closed to anything deriving from that, including ClosedReason.
Reopening keeps the history. That is the whole point of it existing rather than telling somebody to raise a second case: the conversation that explains the problem is the one already written down.
Throws rather than defaulting to nothing, because a silent no-op would leave a case closed while the caller was told it had reopened. A store written before this existed keeps compiling; it says plainly that it cannot do this if asked.
SetAnswerRatingAsync(string, string, int, SupportAnswerRating, CancellationToken)
Sets the rating of the entry at sequence, and what the case counts of its ratings.
Task SetAnswerRatingAsync(string teamKey, string caseId, int sequence, SupportAnswerRating rating, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringsequenceintratingSupportAnswerRatingcancellationTokenCancellationToken
Returns
Remarks
The default throws rather than no-opping, as handing over does: a rating that silently went nowhere would tell the person who gave it that it was kept.
SetAssistantStateAsync(string, string, SupportAssistantState, CancellationToken)
Moves a case to state.
Task SetAssistantStateAsync(string teamKey, string caseId, SupportAssistantState state, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringstateSupportAssistantStatecancellationTokenCancellationToken
Returns
Remarks
The default throws rather than no-opping. A store that silently dropped the hand-off would leave the assistant answering a customer who has just asked for a person — reporting success for something that did not happen, which is worse than refusing.
SetMessageDeliveryAsync(string, string, int, SupportMessageDelivery, string, CancellationToken)
Records whether one transcript entry reached the case's channel.
Task SetMessageDeliveryAsync(string teamKey, string caseId, int sequence, SupportMessageDelivery delivery, string error = null, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringsequenceintdeliverySupportMessageDeliveryerrorstringcancellationTokenCancellationToken
Returns
Remarks
A second write after the message itself, deliberately. The case is written first and is authoritative; the channel is a projection, so a channel that is slow or down must not delay or block the record of what somebody said.
error is kept only with Failed; any other status
clears it, so an entry sent again successfully stops explaining a failure it no longer has.
TryAssignCaseAsync(string, string, SupportMessage, CancellationToken)
Gives an unassigned case to a team, returning whether this call did it.
Task<bool> TryAssignCaseAsync(string caseId, string teamKey, SupportMessage assignmentMessage, CancellationToken cancellationToken = default)
Parameters
caseIdstringteamKeystringassignmentMessageSupportMessagecancellationTokenCancellationToken
Returns
Remarks
Conditional on the case still having no team, so two agents triaging the same queue cannot both assign it and the second is told it changed nothing. The same shape as TryCloseForInactivityAsync(string, string, DateTime, SupportMessage, CancellationToken): let the write decide, rather than reading and then acting on what was true a moment ago.
TryCloseForInactivityAsync(string, string, DateTime, SupportMessage, CancellationToken)
Closes a case for inactivity, but only if it is still open. Returns whether this call closed it.
Task<bool> TryCloseForInactivityAsync(string teamKey, string caseId, DateTime closedAt, SupportMessage closureMessage, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringclosedAtDateTimeclosureMessageSupportMessagecancellationTokenCancellationToken
Returns
Remarks
The condition is the point. Two instances sweeping together both see the same case; the update applies only while the status is still open, so exactly one closes it and the other is told it did not. That is the same shape ISupportEventLedger uses — let the write decide, rather than reading and then acting on what was true a moment ago.
The actor is the store's to set, not the caller's: it records AutoClose, which is what ClosedReason reads. A caller that could pass its own actor could make an automatic closure claim to be a person's.
Defaults to closing nothing, for the same reason as the query above.
TrySetVisibilityAsync(string, string, SupportCaseVisibility, CancellationToken)
Sets a case's visibility, returning whether it did — false when the case is missing or closed.
Task<bool> TrySetVisibilityAsync(string teamKey, string caseId, SupportCaseVisibility visibility, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringvisibilitySupportCaseVisibilitycancellationTokenCancellationToken
Returns
Remarks
Open or closed alike: who may read a case's history matters as much once it is over.
Defaults to false, so a store written before visibility existed keeps compiling and refuses the change.