Interface ISupportCaseStore
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
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.
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
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.
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
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.
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
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.
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.
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, CancellationToken)
Records whether one transcript entry reached the case's channel.
Task SetMessageDeliveryAsync(string teamKey, string caseId, int sequence, SupportMessageDelivery delivery, CancellationToken cancellationToken = default)
Parameters
teamKeystringcaseIdstringsequenceintdeliverySupportMessageDeliverycancellationTokenCancellationToken
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.
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.