diff --git a/pkg/jmap/jmap_api_email.go b/pkg/jmap/jmap_api_email.go index 8aeb73e48c..0f42197a94 100644 --- a/pkg/jmap/jmap_api_email.go +++ b/pkg/jmap/jmap_api_email.go @@ -799,7 +799,7 @@ func (j *Client) QueryEmailSummaries(accountIds []string, session *Session, ctx invocations[i*2+0] = invocation(CommandEmailQuery, EmailQueryCommand{ AccountId: accountId, Filter: filter, - Sort: []EmailComparator{EmailComparator{Property: emailSortByReceivedAt, IsAscending: false}}, + Sort: []EmailComparator{{Property: emailSortByReceivedAt, IsAscending: false}}, Limit: limit, //CalculateTotal: false, }, mcid(accountId, "0")) diff --git a/pkg/jmap/jmap_model.go b/pkg/jmap/jmap_model.go index 29b9f6aeda..6335671544 100644 --- a/pkg/jmap/jmap_model.go +++ b/pkg/jmap/jmap_model.go @@ -114,7 +114,20 @@ type ResourceType string // The Scope data type is used to represent the entities the quota applies to. type Scope string +// Determines which action must be performed by the MUA or MTA upon receiption. +// +// !- `manual-action`: the disposition described by the disposition type was a result of an +// explicit instruction by the user rather than some sort of automatically performed action. +// (This might include the case when the user has manually configured her MUA to automatically +// respond to valid MDN requests.) Unless prescribed otherwise in a particular mail environment, +// in order to preserve the user's privacy, this MUST be the default for MUAs. +// !- `automatic-action`: the disposition described by the disposition type was a result of an +// automatic action rather than an explicit instruction by the user for this message. This +// is typically generated by a Mail Delivery Agent (e.g., MDN generations by Sieve reject action +// [RFC5429], Fax-over-Email [RFC3249], voice message system (see Voice Profile for Internet +// Mail (VPIM) [RFC3801]), or upon delivery to a mailing list). type ActionMode string + type SendingMode string type DispositionTypeOption string @@ -184,11 +197,16 @@ const ( CalendarEventNotificationTypeOptionUpdated = CalendarEventNotificationTypeOption("updated") CalendarEventNotificationTypeOptionDestroyed = CalendarEventNotificationTypeOption("destroyed") + // This represents a single person. PrincipalTypeOptionIndividual = PrincipalTypeOption("individual") - PrincipalTypeOptionGroup = PrincipalTypeOption("group") - PrincipalTypeOptionResource = PrincipalTypeOption("resource") - PrincipalTypeOptionLocation = PrincipalTypeOption("location") - PrincipalTypeOptionOther = PrincipalTypeOption("other") + // This represents a group of people. + PrincipalTypeOptionGroup = PrincipalTypeOption("group") + // This represents some resource, e.g. a projector. + PrincipalTypeOptionResource = PrincipalTypeOption("resource") + // This represents a location. + PrincipalTypeOptionLocation = PrincipalTypeOption("location") + // This represents some other undefined principal. + PrincipalTypeOptionOther = PrincipalTypeOption("other") HttpDigestAlgorithmAdler32 = HttpDigestAlgorithm("adler32") HttpDigestAlgorithmCrc32c = HttpDigestAlgorithm("crc32c") @@ -216,20 +234,64 @@ const ( // The quota information applies to all accounts belonging to the server. ScopeGlobal = Scope("global") - ActionModeManualAction = ActionMode("manual-action") + // The disposition described by the disposition type was a result of an explicit instruction by the + // user rather than some sort of automatically performed action. + // + // (This might include the case when the user has manually configured her MUA to automatically + // respond to valid MDN requests.) + // + // Unless prescribed otherwise in a particular mail environment, in order to preserve the user's + // privacy, this MUST be the default for MUAs. + ActionModeManualAction = ActionMode("manual-action") + + // The disposition described by the disposition type was a result of an automatic action rather than + // an explicit instruction by the user for this message. + // + // This is typically generated by a Mail Delivery Agent (e.g., MDN generations by Sieve reject + // action [RFC5429], Fax-over-Email [RFC3249], voice message system (see Voice Profile + // for Internet Mail (VPIM) [RFC3801]), or upon delivery to a mailing list). ActionModeAutomaticAction = ActionMode("automatic-action") - SendingModeMdnSentManually = SendingMode("mdn-sent-manually") + // The user explicitly gave permission for this particular MDN to be sent. + // + // Unless prescribed otherwise in a particular mail environment, in order to preserve the + // user's privacy, this MUST be the default for MUAs. + SendingModeMdnSentManually = SendingMode("mdn-sent-manually") + + // The MDN was sent because the MUA had previously been configured to do so automatically. SendingModeMdnSentAutomatically = SendingMode("mdn-sent-automatically") - DispositionTypeOptionDeleted = DispositionTypeOption("deleted") - DispositionTypeOptionDispatched = DispositionTypeOption("dispatched") - DispositionTypeOptionDisplayed = DispositionTypeOption("displayed") - DispositionTypeOptionProcessed = DispositionTypeOption("processed") + // The message has been deleted. + // + // The recipient may or may not have seen the message. + // + // The recipient might "undelete" the message at a later time and read the message. + DispositionTypeOptionDeleted = DispositionTypeOption("deleted") - IncludeInAvailabilityAll = IncludeInAvailability("all") + // The message has been sent somewhere in some manner (e.g., printed, faxed, forwarded) without + // necessarily having been previously displayed to the user. + // + // The user may or may not see the message later. + DispositionTypeOptionDispatched = DispositionTypeOption("dispatched") + + // The message has been displayed by the MUA to someone reading the recipient's mailbox. + // + // There is no guarantee that the content has been read or understood. + DispositionTypeOptionDisplayed = DispositionTypeOption("displayed") + + // The message has been processed in some manner (i.e., by some sort of rules or server) + // without being displayed to the user. + // + // The user may or may not see the message later, or there may not even be a human user + // associated with the mailbox. + DispositionTypeOptionProcessed = DispositionTypeOption("processed") + + // All events are considered. + IncludeInAvailabilityAll = IncludeInAvailability("all") + // Events the user is a confirmed or tentative participant of are considered. IncludeInAvailabilityAttending = IncludeInAvailability("attending") - IncludeInAvailabilityNone = IncludeInAvailability("none") + // All events are ignored (but may be considered if also in another calendar). + IncludeInAvailabilityNone = IncludeInAvailability("none") ) var ( @@ -540,16 +602,16 @@ type SessionMDNAccountCapabilities struct { } type SessionAccountCapabilities struct { - Mail SessionMailAccountCapabilities `json:"urn:ietf:params:jmap:mail"` - Submission SessionSubmissionAccountCapabilities `json:"urn:ietf:params:jmap:submission"` - VacationResponse SessionVacationResponseAccountCapabilities `json:"urn:ietf:params:jmap:vacationresponse"` - Sieve SessionSieveAccountCapabilities `json:"urn:ietf:params:jmap:sieve"` - Blob SessionBlobAccountCapabilities `json:"urn:ietf:params:jmap:blob"` - Quota SessionQuotaAccountCapabilities `json:"urn:ietf:params:jmap:quota"` - Contacts SessionContactsAccountCapabilities `json:"urn:ietf:params:jmap:contacts"` - Principals *SessionPrincipalsAccountCapabilities `json:"urn:ietf:params:jmap:principals,omitempty"` - PrincipalsOwner *SessionPrincipalsOwnerAccountCapabilities `json:"urn:ietf:params:jmap:principals:owner,omitempty"` - MDN *SessionMDNAccountCapabilities `json:"urn:ietf:params:jmap:mdn,omitempty"` + Mail *SessionMailAccountCapabilities `json:"urn:ietf:params:jmap:mail,omitempty"` + Submission *SessionSubmissionAccountCapabilities `json:"urn:ietf:params:jmap:submission,omitempty"` + VacationResponse *SessionVacationResponseAccountCapabilities `json:"urn:ietf:params:jmap:vacationresponse,omitempty"` + Sieve *SessionSieveAccountCapabilities `json:"urn:ietf:params:jmap:sieve,omitempty"` + Blob *SessionBlobAccountCapabilities `json:"urn:ietf:params:jmap:blob,omitempty"` + Quota *SessionQuotaAccountCapabilities `json:"urn:ietf:params:jmap:quota,omitempty"` + Contacts *SessionContactsAccountCapabilities `json:"urn:ietf:params:jmap:contacts,omitempty"` + Principals *SessionPrincipalsAccountCapabilities `json:"urn:ietf:params:jmap:principals,omitempty"` + PrincipalsOwner *SessionPrincipalsOwnerAccountCapabilities `json:"urn:ietf:params:jmap:principals:owner,omitempty"` + MDN *SessionMDNAccountCapabilities `json:"urn:ietf:params:jmap:mdn,omitempty"` } type Account struct { @@ -558,7 +620,8 @@ type Account struct { // This is true if the account belongs to the authenticated user rather than a group account or a personal account of another user that has been shared with them. IsPersonal bool `json:"isPersonal"` // This is true if the entire account is read-only. - IsReadOnly bool `json:"isReadOnly"` + IsReadOnly bool `json:"isReadOnly"` + // Account capabilities. AccountCapabilities SessionAccountCapabilities `json:"accountCapabilities"` } @@ -4119,10 +4182,45 @@ type Principal struct { Accounts map[string]Account `json:"accounts,omitempty"` } -// TODO https://jmap.io/spec-sharing.html#object-properties -type ShareNotification struct { +type ShareChangePerson struct { + // The name of the person who made the change. + Name string `json:"name"` + // The email of the person who made the change, or null if no email is available. + Email string `json:"email,omitempty"` + // The id of the Principal corresponding to the person who made the change, or null if no associated principal. + PrincipalId string `json:"principalId,omitempty"` } +type ShareNotification struct { + // The id of the `ShareNotification`. + Id string `json:"id"` + + // The time this notification was created. + Created UTCDate `json:"created,omitzero"` + + // Who made the change. + ChangedBy ShareChangePerson `json:"changedBy"` + + // The name of the data type for the object whose permissions have changed, e.g. `Calendar` or `Mailbox`. + ObjectType ObjectType `json:"objectType"` + + // The id of the account where this object exists. + ObjectAccountId string `json:"objectAccountId"` + + // The id of the object that this notification is about. + ObjectId string `json:"objectId"` + + // The name of the object at the time the notification was made. + Name string `json:"name"` + + // The `myRights` property of the object for the user before the change. + OldRights map[string]bool `json:"oldRights,omitempty"` + + // The `myRights` property of the object for the user after the change. + NewRights map[string]bool `json:"newRights,omitempty"` +} + +// TODO unused type Shareable struct { // Has the user indicated they wish to see this data? // diff --git a/services/groupware/api-params.yaml b/services/groupware/api-params.yaml index 2b5b56ed1e..20a2e78c63 100644 --- a/services/groupware/api-params.yaml +++ b/services/groupware/api-params.yaml @@ -9,3 +9,5 @@ params: description: The identifier of the AddressBook to perform this operation on calendarid: description: The identifier of the Calendar to perform this operation on + tasklistid: + description: The identifier of the TaskList to perform this operation on diff --git a/services/groupware/apidoc.yml b/services/groupware/apidoc.yml index c4bcbc26f3..d0fd057647 100644 --- a/services/groupware/apidoc.yml +++ b/services/groupware/apidoc.yml @@ -29,6 +29,12 @@ tags: - name: event x-displayName: Events description: APIs about calendar events + - name: tasklist + x-displayName: TaskLists + description: APIs about task lists + - name: task + x-displayName: Tasks + description: APIs about tasks - name: vacation x-displayName: Vacation Responses description: APIs about vacation responses @@ -53,6 +59,10 @@ x-tagGroups: tags: - calendar - event + - name: Tasks + tags: + - tasklist + - task components: securitySchemes: api: diff --git a/services/groupware/pkg/groupware/groupware_api_calendars.go b/services/groupware/pkg/groupware/groupware_api_calendars.go index 2cab9bf5e1..c9bfa351f3 100644 --- a/services/groupware/pkg/groupware/groupware_api_calendars.go +++ b/services/groupware/pkg/groupware/groupware_api_calendars.go @@ -39,7 +39,7 @@ type SwaggerGetCalendarById200 struct { } // swagger:route GET /groupware/accounts/{account}/calendars/{calendarid} calendar calendar_by_id -// Get all calendars of an account. +// Get a calendar of an account by its identifier. // // responses: // @@ -68,7 +68,7 @@ type SwaggerGetEventsInCalendar200 struct { } // swagger:route GET /groupware/accounts/{account}/calendars/{calendarid}/events event events_in_addressbook -// Get all the events in a calendarof an account by its identifier. +// Get all the events in a calendar of an account by its identifier. // // responses: // diff --git a/services/groupware/pkg/groupware/groupware_api_tasklists.go b/services/groupware/pkg/groupware/groupware_api_tasklists.go new file mode 100644 index 0000000000..878935cc1f --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_api_tasklists.go @@ -0,0 +1,89 @@ +package groupware + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/opencloud-eu/opencloud/pkg/jmap" +) + +// When the request succeeds. +// swagger:response GetTaskLists200 +type SwaggerGetTaskLists200 struct { + // in: body + Body []jmap.TaskList +} + +// swagger:route GET /groupware/accounts/{account}/tasklists tasklist tasklists +// Get all tasklists of an account. +// +// responses: +// +// 200: GetTaskLists200 +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 +func (g *Groupware) GetTaskLists(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + return response(AllTaskLists, req.session.State) + }) +} + +// When the request succeeds. +// swagger:response GetTaskListById200 +type SwaggerGetTaskListById200 struct { + // in: body + Body struct { + *jmap.TaskList + } +} + +// swagger:route GET /groupware/accounts/{account}/tasklists/{tasklistid} tasklist tasklist_by_id +// Get a tasklist by its identifier. +// +// responses: +// +// 200: GetTaskListById200 +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 +func (g *Groupware) GetTaskListById(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + tasklistId := chi.URLParam(r, UriParamTaskListId) + // TODO replace with proper implementation + for _, tasklist := range AllTaskLists { + if tasklist.Id == tasklistId { + return response(tasklist, req.session.State) + } + } + return notFoundResponse(req.session.State) + }) +} + +// When the request succeeds. +// swagger:response GetTasksInTaskList200 +type SwaggerGetTasksInTaskList200 struct { + // in: body + Body []jmap.Task +} + +// swagger:route GET /groupware/accounts/{account}/tasklists/{tasklistid}/tasks task tasks_in_tasklist +// Get all the tasks in a tasklist of an account by its identifier. +// +// responses: +// +// 200: GetTasksInTaskList200 +// 400: ErrorResponse400 +// 404: ErrorResponse404 +// 500: ErrorResponse500 +func (g *Groupware) GetTasksInTaskList(w http.ResponseWriter, r *http.Request) { + g.respond(w, r, func(req Request) Response { + tasklistId := chi.URLParam(r, UriParamTaskListId) + // TODO replace with proper implementation + tasks, ok := TaskMapByTaskListId[tasklistId] + if !ok { + return notFoundResponse(req.session.State) + } + return response(tasks, req.session.State) + }) +} diff --git a/services/groupware/pkg/groupware/groupware_framework.go b/services/groupware/pkg/groupware/groupware_framework.go index 68ec68929c..8f475906b9 100644 --- a/services/groupware/pkg/groupware/groupware_framework.go +++ b/services/groupware/pkg/groupware/groupware_framework.go @@ -497,23 +497,6 @@ func (g *Groupware) serveError(w http.ResponseWriter, r *http.Request, error *Er render.Render(w, r, errorResponses(*error)) } -func (g *Groupware) getSession(user user) (*jmap.Session, *GroupwareError) { - session, ok, gwerr := g.session(user, g.logger) - if gwerr != nil { - g.metrics.SessionFailureCounter.Inc() - g.logger.Error().Str("code", gwerr.Code).Str("error", gwerr.Title).Str("detail", gwerr.Detail).Msg("failed to determine JMAP session") - return nil, gwerr - } - if !ok { - // no session = authentication failed - gwerr = &ErrorInvalidAuthentication - g.metrics.SessionFailureCounter.Inc() - g.logger.Error().Msg("could not authenticate, failed to find Session") - return nil, gwerr - } - return &session, nil -} - func (g *Groupware) withSession(w http.ResponseWriter, r *http.Request, handler func(r Request) Response) (Response, bool) { ctx := r.Context() sl := g.logger.SubloggerWithRequestID(ctx) diff --git a/services/groupware/pkg/groupware/groupware_mock_tasks.go b/services/groupware/pkg/groupware/groupware_mock_tasks.go new file mode 100644 index 0000000000..a9a8c8ddd2 --- /dev/null +++ b/services/groupware/pkg/groupware/groupware_mock_tasks.go @@ -0,0 +1,207 @@ +package groupware + +import ( + "github.com/opencloud-eu/opencloud/pkg/jmap" + "github.com/opencloud-eu/opencloud/pkg/jscalendar" +) + +var TL1 = jmap.TaskList{ + Id: "aemua9ai", + Role: jmap.TaskListRoleInbox, + Name: "Your Tasks", + Description: "Your default list of tasks", + Color: "purple", + KeywordColors: map[string]string{ + "todo": "blue", + "done": "green", + }, + CategoryColors: map[string]string{ + "work": "magenta", + }, + SortOrder: 1, + IsSubscribed: true, + TimeZone: "CEST", + WorkflowStatuses: []string{ + "new", "todo", "in-progress", "done", + }, + ShareWith: map[string]jmap.TaskRights{ + "eefeeb4p": { + MayReadItems: true, + MayWriteAll: false, + MayWriteOwn: true, + MayUpdatePrivate: false, + MayRSVP: false, + MayAdmin: false, + MayDelete: false, + }, + }, + MyRights: &jmap.TaskRights{ + MayReadItems: true, + MayWriteAll: true, + MayWriteOwn: true, + MayUpdatePrivate: true, + MayRSVP: true, + MayAdmin: false, + MayDelete: false, + }, + DefaultAlertsWithTime: map[string]jscalendar.Alert{ + "saenee7a": { + Type: jscalendar.AlertType, + Trigger: jscalendar.OffsetTrigger{ + Type: jscalendar.OffsetTriggerType, + Offset: "-PT10M", + RelativeTo: jscalendar.RelativeToStart, + }, + Action: jscalendar.AlertActionEmail, + }, + }, + DefaultAlertsWithoutTime: map[string]jscalendar.Alert{ + "xiipaew9": { + Type: jscalendar.AlertType, + Trigger: jscalendar.OffsetTrigger{ + Type: jscalendar.OffsetTriggerType, + Offset: "-PT12H", + RelativeTo: jscalendar.RelativeToStart, + }, + Action: jscalendar.AlertActionDisplay, + }, + }, +} + +var T1 = jmap.Task{ + Id: "laoj0ahk", + TaskListId: TL1.Id, + IsDraft: false, + UtcStart: jmap.UTCDate{Time: mustParseTime("2025-10-02T10:00:00Z")}, + UtcDue: jmap.UTCDate{Time: mustParseTime("2025-10-12T18:00:00Z")}, + SortOrder: 1, + WorkflowStatus: "new", + Task: jscalendar.Task{ + Type: jscalendar.TaskType, + Object: jscalendar.Object{ + CommonObject: jscalendar.CommonObject{ + Uid: "7da0d4a2-385c-430f-9022-61db302734d9", + ProdId: "Mock 0.0", + Created: mustParseTime("2025-10-01T17:31:49Z"), + Updated: mustParseTime("2025-10-01T17:35:12Z"), + Title: "Crossing the Ring", + Description: "We need to cross the Ring the protomolecule opened.", + DescriptionContentType: "text/plain", + Links: map[string]jscalendar.Link{ + "theisha5": { + Type: jscalendar.LinkType, + Href: "https://static.wikia.nocookie.net/expanse/images/e/ed/S03E09-SlowZone_01.jpg/revision/latest/scale-to-width-down/1000?cb=20180611184722", + ContentType: "image/jpeg", + Size: 109212, + Rel: jscalendar.RelIcon, + Display: "sol gate", + Title: "The Sol Ring Gate", + }, + }, + Locale: "en-GB", + Keywords: map[string]bool{ + "todo": true, + }, + Categories: map[string]bool{ + "work": true, + }, + Color: "yellow", + }, + Sequence: 1, + ShowWithoutTime: false, + Locations: map[string]jscalendar.Location{ + "ruoth5uu": { + Type: jscalendar.LocationType, + Name: "Sol Gate", + Description: "We meet at the Sol gate", + LocationTypes: map[jscalendar.LocationTypeOption]bool{ + jscalendar.LocationTypeOptionLandmarkAddress: true, + }, + RelativeTo: jscalendar.LocationRelationStart, + TimeZone: "UTC", + Coordinates: "geo:40.4165583,-3.7063595", + Links: map[string]jscalendar.Link{ + "jeeshei5": { + Type: jscalendar.LinkType, + Href: "https://expanse.fandom.com/wiki/Sol_gate", + ContentType: "text/html", + Title: "The Sol Gate", + }, + }, + }, + }, + Priority: 1, + FreeBusyStatus: jscalendar.FreeBusyStatusBusy, + Privacy: jscalendar.PrivacySecret, + Alerts: map[string]jscalendar.Alert{ + "eiphuw4a": { + Type: jscalendar.AlertType, + Trigger: jscalendar.AbsoluteTrigger{ + Type: jscalendar.AbsoluteTriggerType, + When: mustParseTime("2025-12-01T10:11:12Z"), + }, + Action: jscalendar.AlertActionDisplay, + }, + }, + TimeZone: "UTC", + MayInviteSelf: true, + MayInviteOthers: true, + HideAttendees: true, + }, + Due: jscalendar.LocalDateTime{Time: mustParseTime("2025-12-01T10:11:12Z")}, + Start: jscalendar.LocalDateTime{Time: mustParseTime("2025-10-01T08:00:00Z")}, + EstimatedDuration: "PT8W", + PercentComplete: 5, + Progress: jscalendar.ProgressNeedsAction, + ProgressUpdated: mustParseTime("2025-10-01T08:12:39Z"), + }, + EstimatedWork: 4, + Impact: "block", + IsOrigin: true, + MayInviteSelf: true, + MayInviteOthers: true, + HideAttendees: false, + Checklists: map[string]jmap.Checklist{ + "sae9aimu": { + Type: jmap.ChecklistType, + Title: "Prerequisites", + CheckItems: []jmap.CheckItem{ + { + Type: jmap.CheckItemType, + Title: "Control Medina Station", + SortOrder: 1, + IsComplete: true, + Updated: jmap.UTCDate{Time: mustParseTime("2025-04-01T09:32:10Z")}, + Assignee: &jmap.TaskPerson{ + Type: jmap.TaskPersonType, + Name: "Fred Johnson", + Uri: "mailto:johnson@opa.org", + PrincipalId: "nae5hu9t", + }, + Comments: map[string]jmap.Comment{ + "ooze1iet": { + Type: jmap.CommentType, + Message: "We first need to control Medina Station before we can get through the Sol Gate", + Created: jmap.UTCDate{Time: mustParseTime("2025-04-01T12:11:10Z")}, + Updated: jmap.UTCDate{Time: mustParseTime("2025-04-01T12:29:19Z")}, + Author: &jmap.TaskPerson{ + Type: jmap.TaskPersonType, + Name: "Anderson Dawes", + Uri: "mailto:adawes@opa.org", + PrincipalId: "eshi9oot", + }, + }, + }, + }, + }, + }, + }, +} + +var AllTaskLists = []jmap.TaskList{TL1} + +var TaskMapByTaskListId = map[string][]jmap.Task{ + TL1.Id: { + T1, + }, +} diff --git a/services/groupware/pkg/groupware/groupware_route.go b/services/groupware/pkg/groupware/groupware_route.go index 82f717af74..8a49cc1bc6 100644 --- a/services/groupware/pkg/groupware/groupware_route.go +++ b/services/groupware/pkg/groupware/groupware_route.go @@ -18,6 +18,7 @@ const ( UriParamRole = "role" UriParamAddressBookId = "addressbookid" UriParamCalendarId = "calendarid" + UriParamTaskListId = "tasklistid" QueryParamMailboxSearchName = "name" QueryParamMailboxSearchRole = "role" QueryParamMailboxSearchSubscribed = "subscribed" @@ -101,6 +102,11 @@ func (g *Groupware) Route(r chi.Router) { r.Get("/{calendarid}", g.GetCalendarById) r.Get("/{calendarid}/events", g.GetEventsInCalendar) }) + r.Route("/tasklists", func(r chi.Router) { + r.Get("/", g.GetTaskLists) + r.Get("/{tasklistid}", g.GetTaskListById) + r.Get("/{tasklistid}/tasks", g.GetTasksInTaskList) + }) }) r.HandleFunc("/events/{stream}", g.ServeSSE)