Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en mailserver op, og går gennem en indbakke, der kan være minutter om det. Det, der manglede, var en URL, man kan smide en linje tekst efter — fra en genvej på telefonen, et script, en tjeneste, der kan kalde en URL. Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej, der bare sender det, man har markeret. Første linje bliver til opgaven, resten til noten under den. Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma" betyder det samme her som i feltet øverst i fladen — dét er hele pointen med at have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder. Sit eget token frem for at låne mailens. De to er den samme slags hemmelighed, men de slipper ud hver for sig: en URL, der har ligget i en genvej på en telefon, der blev væk, skal kunne skiftes uden at mailadressen — som står i andres adressebøger — skifter med. Og ikke et API-token. Et token herfra kan én ting: lave en opgave i den persons indbakke. Det er hele forskellen på en hemmelighed, man kan lægge i et script på en anden maskine, og en, man ikke kan. Kun POST. En GET ville være nemmere at kalde, og det er præcis problemet: forudindlæsning i en browser, en linkkontrol i en chat og en historik, der bliver besøgt igen, ville alle lave opgaver, ingen har bedt om. Sin egen kvote frem for postens, så en travl integration ikke kan lukke for den indgående post. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
682 lines
22 KiB
Go
682 lines
22 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/kristianwind/verdande/internal/ai"
|
|
"github.com/kristianwind/verdande/internal/auth"
|
|
"github.com/kristianwind/verdande/internal/quickadd"
|
|
"github.com/kristianwind/verdande/internal/safedial"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
// --- mail to task -------------------------------------------------------------------
|
|
|
|
type mailAddressResponse struct {
|
|
Address string `json:"address"`
|
|
// Configured says whether a mail server has been set up at all. Without one
|
|
// the address is still minted and still valid — the token is real, and the
|
|
// endpoint that receives mail works — but nothing on the internet routes to
|
|
// it yet, and an interface that shows the address without saying so is
|
|
// promising something that will not happen.
|
|
Configured bool `json:"configured"`
|
|
}
|
|
|
|
// handleGetMailAddress returns the personal address that turns an email into a
|
|
// task, minting the token on first ask.
|
|
//
|
|
// The token goes in the local part rather than the domain — todo+<token>@domain —
|
|
// because that is what a single Mailcow alias can route without a wildcard domain
|
|
// or a DNS change per user.
|
|
func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
|
|
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
|
|
if err != nil {
|
|
s.internal(w, r, "mail token", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, mailAddressResponse{
|
|
Address: s.mailAddress(token),
|
|
Configured: s.cfg.SMTP.Host != "",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
|
token, err := auth.NewToken()
|
|
if err != nil {
|
|
s.internal(w, r, "generate mail token", err)
|
|
return
|
|
}
|
|
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
|
s.internal(w, r, "set mail token", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, mailAddressResponse{
|
|
Address: s.mailAddress(token),
|
|
Configured: s.cfg.SMTP.Host != "",
|
|
})
|
|
}
|
|
|
|
func (s *Server) mailAddress(token string) string {
|
|
domain := feedDomain(s.cfg.BaseURL)
|
|
if from := s.cfg.SMTP.From; strings.Contains(from, "@") {
|
|
// The mail server's own domain, which is the one that can actually route
|
|
// this — the web address may well be a tunnel hostname that receives no mail.
|
|
//
|
|
// Except when it is the default. VERDANDE_SMTP_FROM falls back to
|
|
// verdande@localhost, so an instance with no mail server at all was
|
|
// handing out todo+…@localhost: an address that looks real, cannot
|
|
// possibly receive anything, and gives no hint which of the two it is.
|
|
// The public hostname is at least a domain that exists.
|
|
if d := from[strings.Index(from, "@")+1:]; d != "" && d != "localhost" {
|
|
domain = d
|
|
}
|
|
}
|
|
return "todo+" + token + "@" + domain
|
|
}
|
|
|
|
type inboundMailRequest struct {
|
|
// To is the address the mail was sent to, from which the token is read.
|
|
To string `json:"to"`
|
|
From string `json:"from"`
|
|
Subject string `json:"subject"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// handleInboundMail turns a delivered email into a task.
|
|
//
|
|
// Mailcow delivers to this endpoint over LMTP or a small forwarding script; either
|
|
// way what arrives here is already-parsed mail. Authentication is the token in the
|
|
// recipient address, which is the only credential an inbound mail can carry.
|
|
//
|
|
// The subject is parsed with the quick-add parser, so "Fakturer Anders p1 #Firma"
|
|
// in a subject line does what it does everywhere else in the app.
|
|
func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
|
var req inboundMailRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
|
|
token := mailToken(req.To)
|
|
if token == "" {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
|
|
return
|
|
}
|
|
user, err := s.db.UserByMailToken(r.Context(), token)
|
|
if err != nil {
|
|
// Deliberately identical to a malformed address: an inbound endpoint that
|
|
// distinguishes them is an oracle for which tokens are live.
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
|
|
return
|
|
}
|
|
|
|
subject := strings.TrimSpace(req.Subject)
|
|
if subject == "" {
|
|
subject = "(uden emne)"
|
|
}
|
|
|
|
parsed := quickadd.Parse(subject, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
|
content := parsed.Content
|
|
if strings.TrimSpace(content) == "" {
|
|
content = subject
|
|
}
|
|
|
|
projectID := ""
|
|
if parsed.Project != "" {
|
|
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
|
projectID = id
|
|
}
|
|
}
|
|
if projectID == "" {
|
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
|
s.internal(w, r, "inbox", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
task := &store.Task{
|
|
ProjectID: projectID, Content: content, Priority: parsed.Priority,
|
|
Description: strings.TrimSpace(req.Body), DueDate: parsed.DueDate,
|
|
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
|
}
|
|
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
|
s.internal(w, r, "create task from mail", err)
|
|
return
|
|
}
|
|
|
|
s.publish(projectID, "task.created", toTaskJSON(*task))
|
|
s.log.Info("task created from mail", "user", user.ID, "from", req.From)
|
|
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID})
|
|
}
|
|
|
|
// --- en krog ind i indbakken --------------------------------------------------------
|
|
|
|
type hookURLResponse struct {
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// handleGetHookURL returns the personal address another program can push a task
|
|
// to, minting the token on first ask.
|
|
//
|
|
// Der var to veje ind i forvejen, og ingen af dem passer til "gem den her, mens
|
|
// jeg står med telefonen i hånden". API-tokenet giver adgang til alt og skal have
|
|
// en JSON-krop skrevet rigtigt; mailadressen virker først, når nogen har sat en
|
|
// mailserver op. Det, der manglede, var en URL, man kan smide en linje tekst efter
|
|
// — fra en genvej på telefonen, et script, en anden tjeneste.
|
|
func (s *Server) handleGetHookURL(w http.ResponseWriter, r *http.Request) {
|
|
token, err := s.db.EnsureHookToken(r.Context(), userFrom(r.Context()).ID)
|
|
if err != nil {
|
|
s.internal(w, r, "hook token", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
|
|
}
|
|
|
|
// handleRotateHookURL mints a new one and retires the old.
|
|
//
|
|
// Den eneste vej tilbage, når en URL er sluppet ud: den står i en genvej på en
|
|
// telefon eller i et script på en anden maskine, og der er ikke andet at trække
|
|
// tilbage end tokenet selv.
|
|
func (s *Server) handleRotateHookURL(w http.ResponseWriter, r *http.Request) {
|
|
token, err := auth.NewToken()
|
|
if err != nil {
|
|
s.internal(w, r, "generate hook token", err)
|
|
return
|
|
}
|
|
if err := s.db.SetHookToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
|
|
s.internal(w, r, "set hook token", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, hookURLResponse{URL: s.hookURL(token)})
|
|
}
|
|
|
|
func (s *Server) hookURL(token string) string {
|
|
return strings.TrimSuffix(s.cfg.BaseURL, "/") + "/inbound/hook/" + token
|
|
}
|
|
|
|
type hookRequest struct {
|
|
// Text er linjen, der bliver til opgaven. `title` og `content` tages med som
|
|
// de samme, fordi det er dem, andre tjenester i forvejen kalder feltet — og en
|
|
// integration, der fejler på et feltnavn, fejler tavst hos den, der satte den
|
|
// op.
|
|
Text string `json:"text"`
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
// Note er det, der står under opgaven. `body` og `description` ligeså.
|
|
Note string `json:"note"`
|
|
Body string `json:"body"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
func (h hookRequest) line() string {
|
|
for _, v := range []string{h.Text, h.Title, h.Content} {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h hookRequest) note() string {
|
|
for _, v := range []string{h.Note, h.Body, h.Description} {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// handleInboundHook turns a POST from somewhere else into a task in the inbox.
|
|
//
|
|
// Tokenet i stien er hele legitimationen, som i kalenderfeedet og den indgående
|
|
// post: den, der kalder, er et script eller en genvej på en telefon, og hverken
|
|
// kan logge ind. Derfor kan et token herfra også kun én ting — lave en opgave i
|
|
// den ene persons indbakke — hvor et API-token kan alt.
|
|
//
|
|
// Kroppen læses på tre måder, fordi de tre er dem, man møder: JSON fra en
|
|
// tjeneste, et formularfelt fra en webhook-formular, og ren tekst fra en genvej,
|
|
// der bare sender det, man har markeret. Første linje bliver til opgaven, resten
|
|
// til noten under den — det er sådan, en tekst, man har delt, ser ud.
|
|
//
|
|
// Linjen læses med quick-add-parseren, så "Ring til Anders i morgen p1 #Firma"
|
|
// betyder det samme her som i feltet øverst i fladen. Det er hele pointen med at
|
|
// have én parser: den vej, teksten kom ind ad, ændrer ikke, hvad den betyder.
|
|
func (s *Server) handleInboundHook(w http.ResponseWriter, r *http.Request) {
|
|
user, err := s.db.UserByHookToken(r.Context(), chi.URLParam(r, "token"))
|
|
if err != nil {
|
|
// Samme svar som en adresse, der aldrig har eksisteret: et endpoint, der
|
|
// kender forskel, er en maskine til at gætte tokens med.
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
|
|
return
|
|
}
|
|
|
|
line, note := s.readHookBody(r)
|
|
if strings.TrimSpace(line) == "" {
|
|
writeFieldErrors(w, map[string]string{"text": "required"})
|
|
return
|
|
}
|
|
|
|
parsed := quickadd.Parse(line, time.Now().In(userLocation(user.Timezone)), user.Locale)
|
|
content := parsed.Content
|
|
if strings.TrimSpace(content) == "" {
|
|
content = line
|
|
}
|
|
|
|
projectID := ""
|
|
if parsed.Project != "" {
|
|
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
|
|
projectID = id
|
|
}
|
|
}
|
|
if projectID == "" {
|
|
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
|
s.internal(w, r, "inbox", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
task := &store.Task{
|
|
ProjectID: projectID, Content: content, Priority: parsed.Priority,
|
|
Description: strings.TrimSpace(note), DueDate: parsed.DueDate,
|
|
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
|
}
|
|
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
|
s.internal(w, r, "create task from hook", err)
|
|
return
|
|
}
|
|
|
|
s.publish(projectID, "task.created", toTaskJSON(*task))
|
|
s.log.Info("task created from hook", "user", user.ID)
|
|
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID, "content": task.Content})
|
|
}
|
|
|
|
// readHookBody er de tre måder, en linje tekst kommer ind ad.
|
|
//
|
|
// Rækkefølgen er efter, hvor sikkert man ved, hvad man har: en erklæret
|
|
// Content-Type tros, og ellers falder det tilbage på ren tekst — som er det, en
|
|
// genvej på en telefon sender, når den ikke er blevet bedt om andet.
|
|
//
|
|
// En krop, der siger JSON, men ikke er det, læses ikke som tekst bagefter. Den,
|
|
// der satte integrationen op, har skrevet noget forkert, og en opgave, der hedder
|
|
// `{"txt": "køb mælk"}`, er en dårligere måde at finde ud af det på end et 422.
|
|
func (s *Server) readHookBody(r *http.Request) (line, note string) {
|
|
const maxBody = 64 << 10
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
kind := r.Header.Get("Content-Type")
|
|
|
|
switch {
|
|
case strings.Contains(kind, "application/json"):
|
|
var req hookRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return "", ""
|
|
}
|
|
return req.line(), req.note()
|
|
|
|
case strings.Contains(kind, "application/x-www-form-urlencoded"):
|
|
values, err := url.ParseQuery(string(body))
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
req := hookRequest{
|
|
Text: values.Get("text"), Title: values.Get("title"), Content: values.Get("content"),
|
|
Note: values.Get("note"), Body: values.Get("body"), Description: values.Get("description"),
|
|
}
|
|
return req.line(), req.note()
|
|
}
|
|
|
|
// Ren tekst: første linje er opgaven, resten står under den. Sådan ser en tekst,
|
|
// man har delt fra en anden app, ud — en overskrift og noget under.
|
|
text := strings.TrimSpace(string(body))
|
|
first, rest, _ := strings.Cut(text, "\n")
|
|
return first, strings.TrimSpace(rest)
|
|
}
|
|
|
|
// mailToken reads the token out of "Name <todo+TOKEN@domain>".
|
|
func mailToken(address string) string {
|
|
if i := strings.LastIndex(address, "<"); i >= 0 {
|
|
address = address[i+1:]
|
|
}
|
|
address = strings.TrimSuffix(strings.TrimSpace(address), ">")
|
|
|
|
local, _, ok := strings.Cut(address, "@")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
_, token, ok := strings.Cut(local, "+")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return token
|
|
}
|
|
|
|
// --- AI ---------------------------------------------------------------------------------
|
|
|
|
type aiSettings struct {
|
|
Provider string `json:"provider"`
|
|
BaseURL string `json:"base_url"`
|
|
Model string `json:"model"`
|
|
// APIKey is written but never read back — see handleGetAISettings.
|
|
APIKey string `json:"api_key,omitempty"`
|
|
// HasKey tells the settings page whether a key is stored, without showing it.
|
|
HasKey bool `json:"has_key"`
|
|
}
|
|
|
|
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
|
|
if err != nil {
|
|
s.internal(w, r, "ai settings", err)
|
|
return
|
|
}
|
|
// The key is never sent back. A settings page that repopulates a password
|
|
// field is a settings page that will eventually leak one into a screenshot.
|
|
writeJSON(w, http.StatusOK, aiSettings{
|
|
Provider: string(cfg.Provider), BaseURL: cfg.BaseURL,
|
|
Model: cfg.Model, HasKey: cfg.APIKey != "",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
|
var req aiSettings
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
|
|
switch ai.Provider(req.Provider) {
|
|
case ai.ProviderAnthropic, ai.ProviderOpenAI, ai.ProviderGoogle, ai.ProviderCompatible, "":
|
|
default:
|
|
writeFieldErrors(w, map[string]string{
|
|
"provider": "must be anthropic, openai, google or compatible",
|
|
})
|
|
return
|
|
}
|
|
|
|
// The address is checked where it is typed, as well as when it is dialled. A
|
|
// scheme other than http or https is refused outright, for everybody —
|
|
// file:// and gopher:// are not providers.
|
|
//
|
|
// The private-network wall has one exception, and it is the point of the
|
|
// provider it guards. "OpenAI-compatible (Ollama, LM Studio, …)" means a model
|
|
// on this machine or on the desk beside it: 127.0.0.1:11434, 192.168.1.150.
|
|
// safedial refuses exactly those, so the address the field suggested in its
|
|
// own placeholder was an address the server would not accept.
|
|
//
|
|
// Admins only, because the wall is there for a real reason: a server that
|
|
// fetches a user-supplied address lets the person supplying it reach what
|
|
// they cannot reach themselves. An admin on a self-hosted instance already
|
|
// owns the host and everything on its network, so this grants them nothing
|
|
// new. An invited user is a different person, and for them it stays shut.
|
|
if url := strings.TrimSpace(req.BaseURL); url != "" {
|
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
|
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
|
|
return
|
|
}
|
|
if !user.IsAdmin {
|
|
if reason := safedial.CheckURL(url); reason != "" {
|
|
writeFieldErrors(w, map[string]string{"base_url": reason})
|
|
return
|
|
}
|
|
}
|
|
req.BaseURL = url
|
|
}
|
|
|
|
existing, err := s.aiConfig(r, user.ID)
|
|
if err != nil {
|
|
s.internal(w, r, "ai settings", err)
|
|
return
|
|
}
|
|
// An empty key means "leave it alone", not "delete it" — otherwise saving a
|
|
// changed model would silently clear the key.
|
|
key := existing.APIKey
|
|
if req.APIKey != "" {
|
|
key = req.APIKey
|
|
}
|
|
|
|
settings := map[string]any{
|
|
"provider": req.Provider, "base_url": req.BaseURL,
|
|
"model": req.Model, "api_key": key,
|
|
}
|
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
|
|
s.internal(w, r, "save ai settings", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) aiConfig(r *http.Request, userID string) (ai.Config, error) {
|
|
values, err := s.db.UserSettings(r.Context(), userID, "ai")
|
|
if err != nil {
|
|
return ai.Config{}, err
|
|
}
|
|
str := func(key string) string {
|
|
v, _ := values[key].(string)
|
|
return v
|
|
}
|
|
// Worked out per request rather than stored beside the address: it answers
|
|
// "may this person reach the private network", and somebody who is no longer
|
|
// an admin should stop being able to. A flag written at save time would
|
|
// outlive the role that granted it.
|
|
allowPrivate := false
|
|
if u := userFrom(r.Context()); u != nil && u.IsAdmin {
|
|
allowPrivate = true
|
|
}
|
|
return ai.Config{
|
|
Provider: ai.Provider(str("provider")),
|
|
APIKey: str("api_key"),
|
|
BaseURL: str("base_url"),
|
|
Model: str("model"),
|
|
AllowPrivate: allowPrivate,
|
|
}, nil
|
|
}
|
|
|
|
// handleAISplit breaks a task into sub-tasks and creates them.
|
|
func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
taskID := chi.URLParam(r, "taskID")
|
|
|
|
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
|
if err != nil || !role.CanEdit() {
|
|
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
|
return
|
|
}
|
|
cfg, err := s.aiConfig(r, user.ID)
|
|
if err != nil {
|
|
s.internal(w, r, "ai settings", err)
|
|
return
|
|
}
|
|
if !cfg.Configured() {
|
|
// Not an error the user did anything to cause: the feature is simply off.
|
|
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
|
"no AI provider is configured")
|
|
return
|
|
}
|
|
|
|
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
|
if err != nil {
|
|
s.storeError(w, r, "get task", err)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
|
defer cancel()
|
|
|
|
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
|
|
if err != nil {
|
|
s.log.Warn("ai split", "err", err)
|
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
|
return
|
|
}
|
|
|
|
created := make([]taskJSON, 0, len(subtasks))
|
|
for _, content := range subtasks {
|
|
child := &store.Task{
|
|
ProjectID: task.ProjectID, ParentID: task.ID, Content: content,
|
|
Priority: task.Priority, CreatedBy: user.ID,
|
|
}
|
|
if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
|
|
s.internal(w, r, "create subtask", err)
|
|
return
|
|
}
|
|
created = append(created, toTaskJSON(*child))
|
|
}
|
|
|
|
s.activity(r, task.ProjectID, task.ID, "task.split", map[string]any{"count": len(created)})
|
|
writeJSON(w, http.StatusCreated, map[string]any{"subtasks": created})
|
|
}
|
|
|
|
// handleAISummary writes a short prioritisation note over what is outstanding.
|
|
func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
|
|
cfg, err := s.aiConfig(r, user.ID)
|
|
if err != nil {
|
|
s.internal(w, r, "ai settings", err)
|
|
return
|
|
}
|
|
if !cfg.Configured() {
|
|
writeError(w, http.StatusConflict, CodeAINotConfigured,
|
|
"no AI provider is configured")
|
|
return
|
|
}
|
|
|
|
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
|
|
if err != nil {
|
|
s.internal(w, r, "list tasks", err)
|
|
return
|
|
}
|
|
if len(tasks) == 0 {
|
|
writeJSON(w, http.StatusOK, map[string]string{"summary": "Der er ingen åbne opgaver."})
|
|
return
|
|
}
|
|
|
|
lines := make([]string, 0, len(tasks))
|
|
for _, t := range tasks {
|
|
line := "- " + t.Content
|
|
if t.DueDate != "" {
|
|
line += " (forfalder " + t.DueDate + ")"
|
|
}
|
|
if t.Priority < 4 {
|
|
line += " [P" + string(rune('0'+t.Priority)) + "]"
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
ctx, cancel := contextWithTimeout(r, 90*time.Second)
|
|
defer cancel()
|
|
|
|
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
|
|
if err != nil {
|
|
s.log.Warn("ai summary", "err", err)
|
|
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
|
|
}
|
|
|
|
// --- Gmail ----------------------------------------------------------------------------
|
|
|
|
type gmailSettings struct {
|
|
Connected bool `json:"connected"`
|
|
Email string `json:"email,omitempty"`
|
|
// Trigger is what causes a task to be created: a starred message, a label, or
|
|
// both. One-way by design — unstarring does nothing to the task.
|
|
Trigger string `json:"trigger"`
|
|
Label string `json:"label,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
|
if err != nil {
|
|
s.internal(w, r, "gmail mailbox", err)
|
|
return
|
|
}
|
|
if box == nil {
|
|
writeJSON(w, http.StatusOK, gmailSettings{})
|
|
return
|
|
}
|
|
|
|
// The mailbox address, fetched again if it is missing.
|
|
//
|
|
// It is stored at connect time, and the call that fetches it can fail — a slow
|
|
// token, a scope Google had not applied yet. That failure used to be a log line
|
|
// and an empty string, so the page said "an unknown account" and went on saying
|
|
// it forever: the only way back was to disconnect and reconnect, which nobody
|
|
// would guess. Asking again here costs one request on a page nobody opens
|
|
// often, and it means the wrong answer lasts until the next look.
|
|
email := box.Username
|
|
if email == "" && box.RefreshToken != "" {
|
|
if fetched, err := s.gmailProfile(r.Context(), box); err == nil {
|
|
email = fetched
|
|
} else {
|
|
s.log.Warn("gmail profile", "err", err)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, gmailSettings{
|
|
Connected: box.RefreshToken != "",
|
|
Email: email,
|
|
Trigger: box.Trigger,
|
|
Label: box.Label,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
|
var req gmailSettings
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
|
|
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
|
if err != nil {
|
|
s.internal(w, r, "gmail mailbox", err)
|
|
return
|
|
}
|
|
if box == nil {
|
|
// Choosing what makes a task before connecting is allowed, and the choice is
|
|
// kept: the row is the mailbox with no credential in it yet, which reads as
|
|
// not connected everywhere that asks and is skipped by the sweep.
|
|
box = &store.Mailbox{UserID: user.ID, Kind: "gmail"}
|
|
}
|
|
box.Trigger = req.Trigger
|
|
box.Label = req.Label
|
|
|
|
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
|
|
s.internal(w, r, "save gmail settings", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleDisconnectGmail forgets the connection, tokens and all. The tasks it made
|
|
// stay: they are the person's work now, not the mailbox's.
|
|
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
|
|
if err != nil {
|
|
s.internal(w, r, "gmail mailbox", err)
|
|
return
|
|
}
|
|
if box != nil {
|
|
if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil {
|
|
s.internal(w, r, "disconnect gmail", err)
|
|
return
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
var _ = json.Marshal
|