groupware: add mock endpoints for tasklists and tasks

This commit is contained in:
Pascal Bleser
2025-10-02 10:41:22 +02:00
parent 7324292c91
commit c7e122141b
9 changed files with 440 additions and 45 deletions

View File

@@ -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"))

View File

@@ -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?
//

View File

@@ -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

View File

@@ -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:

View File

@@ -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:
//

View File

@@ -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)
})
}

View File

@@ -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)

View File

@@ -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,
},
}

View File

@@ -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)