Compare commits
@@ -79,12 +79,18 @@ jobs:
|
||||
working-directory: web
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
# Chromium only. A smoke test exists to catch a build that does not work at
|
||||
# all; running it three times in three engines finds the same breakage three
|
||||
# times and costs three times as much.
|
||||
- name: Install Chromium
|
||||
# Chromium for the suite, WebKit for one test.
|
||||
#
|
||||
# A smoke test exists to catch a build that does not work at all, and running
|
||||
# the whole thing in three engines finds the same breakage three times for
|
||||
# three times the cost. Focus is the exception, and it took a real report to
|
||||
# learn it: Safari ignores `autofocus` on anything inserted after the page has
|
||||
# loaded, so every inline form in the app opened with no cursor in it. WebKit
|
||||
# here is the same engine Safari is, and `webkit-forms` in the Playwright
|
||||
# config greps a single test out of the suite to run in it.
|
||||
- name: Install browsers
|
||||
working-directory: web
|
||||
run: npx playwright install --with-deps chromium
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
# The suite builds the frontend, embeds it and starts the real binary — see
|
||||
# playwright.config.js. There is nothing to build here first.
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
|
||||
# Frontend
|
||||
node_modules/
|
||||
# iCloud syncs ~/Documents, and it evicts what it thinks is cold — which for a
|
||||
# dependency tree is nearly all of it. An evicted file blocks on read until it is
|
||||
# downloaded again, and a download that stalls hangs the build for good: 0 % CPU,
|
||||
# no output, forever. macOS never syncs anything ending in .nosync, so the tree
|
||||
# lives there and `node_modules` is a symlink to it. Redo it after a fresh clone:
|
||||
#
|
||||
# mv node_modules node_modules.nosync && ln -s node_modules.nosync node_modules
|
||||
#
|
||||
# The real fix is a repo path outside a synced folder.
|
||||
node_modules.nosync/
|
||||
/web/.svelte-kit/
|
||||
/web/build/
|
||||
|
||||
|
||||
@@ -5,7 +5,17 @@
|
||||
# manager and no libc — the attack surface of the running container is the binary.
|
||||
|
||||
# --- 1. the web interface ----------------------------------------------------
|
||||
FROM node:22-alpine AS web
|
||||
#
|
||||
# Pinned to the *build* platform, not the target. What this stage produces is
|
||||
# HTML, CSS and JavaScript, which are the same bytes whatever the image will run
|
||||
# on — so building it once and copying it into both architectures is not an
|
||||
# optimisation, it is the correct thing.
|
||||
#
|
||||
# Without the pin, buildx runs this stage once per target, which means running
|
||||
# Node and esbuild under QEMU for arm64. That crashes: exit 132, SIGILL, an
|
||||
# instruction the emulator does not implement, roughly one release in three. It
|
||||
# looks like a broken build and is a broken emulator.
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||
|
||||
WORKDIR /src
|
||||
COPY web/ ./
|
||||
@@ -24,7 +34,12 @@ RUN if [ -f package.json ]; then \
|
||||
fi
|
||||
|
||||
# --- 2. the binary -----------------------------------------------------------
|
||||
FROM golang:1.26-alpine AS build
|
||||
#
|
||||
# Also pinned to the build platform, and cross-compiled instead. Go does that
|
||||
# natively and CGO is already off, so there is nothing to emulate — where
|
||||
# building *in* the target architecture means running the whole Go toolchain
|
||||
# under QEMU for one of the two.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -37,11 +52,13 @@ COPY . .
|
||||
COPY --from=web /src/build ./cmd/verdande/webbuild
|
||||
|
||||
ARG VERSION=dev
|
||||
# Supplied by buildx per target. The default keeps a plain `docker build` working.
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
# CGO off is what makes this binary static, and it is only possible because the
|
||||
# SQLite driver is pure Go. -trimpath and the empty buildid keep the output
|
||||
# reproducible; -s -w drop the symbol and DWARF tables, which is most of the size.
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
||||
-tags embedweb \
|
||||
-trimpath \
|
||||
-ldflags="-s -w -buildid= -X main.version=${VERSION}" \
|
||||
|
||||
@@ -75,6 +75,8 @@ Apple Reminders, forward mail into it, and add it to Claude as a connector.
|
||||
| Settings: account, notifications, integrations, AI, tokens, data | Done |
|
||||
| Device list: where you are signed in, and signing one out | Done, tested |
|
||||
| User administration: invite to the instance, promote, remove | Done, tested |
|
||||
| Server errors kept in the database, readable after a restart | Done, tested |
|
||||
| PWA icons: PNG for launchers, apple-touch-icon for iOS | Done, smoke-tested |
|
||||
| Invite and password-reset links that open the right page | Done, smoke-tested |
|
||||
| Drag to reorder, in both the list and the board | Done |
|
||||
| Drag a task to another project or another day | Done, smoke-tested |
|
||||
@@ -116,8 +118,15 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|
||||
|---|---|
|
||||
|  |  |
|
||||
| Board view — one column per section. | The light theme. |
|
||||
|  |  |
|
||||
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
|
||||
|  |  |
|
||||
| Five themes, all measured for contrast. | What has been done on the server. |
|
||||
|
||||
Regenerate these with `go run ./tools/shots` against a running instance.
|
||||
Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
|
||||
the real binary, creates its own account, fills it with demo data and photographs
|
||||
eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
|
||||
still works, and is the way to photograph an instance that is already running.)
|
||||
|
||||
## Running it
|
||||
|
||||
|
||||
@@ -226,6 +226,43 @@ paths:
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
"429": { $ref: "#/components/responses/RateLimited" }
|
||||
|
||||
/api/v1/auth/sidebar-sections:
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: Which sidebar headings are folded away.
|
||||
description: |
|
||||
On the account rather than in the browser. A project group already carries
|
||||
`collapsed` on its own row for the same reason: folding a heading is a
|
||||
statement about the work — "I am not in Etiketter at the moment" — and that
|
||||
is as true on a laptop as on a desktop. The sidebar's *width* is the
|
||||
opposite case and stays in the browser, because a narrow laptop and a wide
|
||||
monitor deserve different answers.
|
||||
|
||||
Its own route rather than a field on `PATCH /auth/me`. That one backs a
|
||||
form with a save button and validates three fields together; this writes on
|
||||
every click of a chevron, and sharing the path would mean a fold sending a
|
||||
name and a timezone it was never asked to change.
|
||||
|
||||
The keys belong to the interface and are not checked here. An unknown one
|
||||
costs nothing — the sidebar folds what it recognises and ignores the rest —
|
||||
so renaming or removing a heading leaves a harmless entry rather than
|
||||
needing a migration.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
sections:
|
||||
type: array
|
||||
maxItems: 20
|
||||
items: { type: string }
|
||||
example: ["labels", "filters"]
|
||||
responses:
|
||||
"204": { description: Saved. }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
|
||||
/api/v1/auth/me:
|
||||
get:
|
||||
tags: [auth]
|
||||
@@ -856,6 +893,315 @@ paths:
|
||||
"204": { description: Deleted. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/activity:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: What has been done on this instance. Administrators only.
|
||||
description: |
|
||||
The same rows as `/projects/{projectID}/activity`, read across every
|
||||
project instead of one at a time.
|
||||
|
||||
The per-project history is a question a project's members ask about their
|
||||
own work. This is a different question — "what has happened on this
|
||||
server" — and nothing could answer it: the per-project endpoint takes one
|
||||
project id, and an administrator is not necessarily a member of the
|
||||
project they need to look into.
|
||||
|
||||
It is the other half of `/errors`. That one is what broke; this is what was
|
||||
done.
|
||||
|
||||
Rows outlive the accounts that wrote them. `activity.user_id` is
|
||||
`ON DELETE SET NULL`, so a deleted account leaves its trail behind with
|
||||
`user_id` and `user_name` absent — a log that lost those rows would be
|
||||
missing the part most worth keeping.
|
||||
|
||||
Paged with a keyset cursor rather than an offset. The log is written to
|
||||
constantly and read rarely, so a page fetched a minute after the one
|
||||
before it would be shifted by everything written in between: a row shown
|
||||
twice and the row behind it skipped.
|
||||
parameters:
|
||||
- { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
|
||||
- name: before
|
||||
in: query
|
||||
schema: { type: string }
|
||||
description: |
|
||||
`next_cursor` from the previous page, as `<unix>:<id>`. Both halves
|
||||
are required: the log is written in whole seconds, so a timestamp
|
||||
alone cannot separate two rows written in the same one.
|
||||
example: "1755432000:01J8Z9"
|
||||
- { name: user_id, in: query, schema: { type: string } }
|
||||
- { name: project_id, in: query, schema: { type: string } }
|
||||
- name: event
|
||||
in: query
|
||||
schema: { type: string }
|
||||
description: An exact event name, as listed by `/activity/events`.
|
||||
example: task.completed
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
activity:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
at: { type: string, format: date-time }
|
||||
project_id: { type: string }
|
||||
project_name: { type: string }
|
||||
task_id: { type: string }
|
||||
user_id:
|
||||
type: string
|
||||
description: Absent when the account has been deleted.
|
||||
user_name:
|
||||
type: string
|
||||
description: Absent when the account has been deleted.
|
||||
event: { type: string, example: task.completed }
|
||||
payload: { type: object, additionalProperties: true }
|
||||
next_cursor:
|
||||
type: string
|
||||
description: |
|
||||
Absent on the last page. Present only when the page came
|
||||
back full — a short page is the end of the log, and a
|
||||
cursor there invites one more request that can only come
|
||||
back empty.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/activity/events:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: The event names the log contains, with counts. Administrators only.
|
||||
description: |
|
||||
What the `event` filter is drawn from, most frequent first.
|
||||
|
||||
Offered rather than typed. Event names are an internal vocabulary —
|
||||
`member.role_changed`, not a sentence anybody would guess — so a text
|
||||
field for them is a field that returns nothing until you have read the
|
||||
source. The counts are worth having on their own: they are the shape of
|
||||
what the instance does.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
event: { type: string, example: task.completed }
|
||||
count: { type: integer }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/gmail/client:
|
||||
get:
|
||||
tags: [integrations]
|
||||
summary: The Gmail OAuth client this instance uses. Administrators only.
|
||||
description: |
|
||||
The registration Google requires, enterable rather than deployed.
|
||||
|
||||
The one click that opens Google's consent screen already exists. What it
|
||||
needs behind it is a registered client, and no app can conjure one: Google
|
||||
issues no Gmail access to an unregistered client, and `gmail.readonly` is a
|
||||
restricted scope, so an id shipped inside a public image would be both
|
||||
extractable from it and unusable without Google's security review of every
|
||||
deployment that used it.
|
||||
|
||||
The registration is a one-off. Having to edit a Rune's manifest and
|
||||
recreate the container to paste the result was not, which is what this
|
||||
replaces.
|
||||
|
||||
`VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` still win when they are set. A
|
||||
value in the environment is a deliberate deployment decision — under
|
||||
version control, reapplied on every recreate — and a form that could
|
||||
quietly override it would make the manifest a lie. `from_env` says so, and
|
||||
`PUT` answers 409.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/GmailClient" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
put:
|
||||
tags: [integrations]
|
||||
summary: Set the Gmail OAuth client. Administrators only.
|
||||
description: |
|
||||
An empty `client_secret` means "leave the stored one alone", not "delete
|
||||
it" — otherwise correcting a typo in the id would silently clear the
|
||||
secret. The secret is never read back, for the same reason the AI key is
|
||||
not: a settings page that repopulates a password field is one that will
|
||||
eventually leak into a screenshot.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
client_id: { type: string }
|
||||
client_secret: { type: string }
|
||||
responses:
|
||||
"204": { description: Saved. }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"409":
|
||||
description: The environment sets it, so this cannot.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/backups:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: The nightly backups. Administrators only.
|
||||
description: |
|
||||
The backup job has run since the beginning and nothing showed it. A backup
|
||||
nobody can see is a backup nobody knows has been failing, and the failure
|
||||
is quiet: a volume that went read-only, a disk that filled, a container
|
||||
that has not been up at midnight all week. The first anybody hears of it
|
||||
is the day they need one.
|
||||
|
||||
The most recent fourteen files are kept — counted by file rather than by
|
||||
age, so a container that was off for a month does not come back and delete
|
||||
every backup for being too old. Rows older than that stay in the list with
|
||||
`present: false`: a true record of a backup that was made and a file that
|
||||
is no longer there.
|
||||
|
||||
Sessions-only as well as administrators-only. A backup is a complete copy
|
||||
of the database, so this is everybody's data in one file.
|
||||
parameters:
|
||||
- { name: limit, in: query, schema: { type: integer, default: 30, maximum: 100 } }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
backups:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/BackupRun" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Take a backup now. Administrators only.
|
||||
description: |
|
||||
The same work the nightly job does, without its "one a day" check.
|
||||
|
||||
Two reasons it exists: waiting until tonight to find out whether backups
|
||||
work at all is how somebody finds out on the day they need one, and it is
|
||||
the only way to get a snapshot from *before* something you are about to
|
||||
do.
|
||||
|
||||
Synchronous — a homelab database is a second or two, and a button that
|
||||
returns immediately and says nothing is a button people press twice.
|
||||
responses:
|
||||
"201":
|
||||
description: Written.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/BackupRun" }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"500":
|
||||
description: |
|
||||
The snapshot failed — a full volume, or one that has gone read-only,
|
||||
which is precisely the condition this button exists to surface.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
|
||||
/api/v1/backups/{backupID}:
|
||||
parameters:
|
||||
- { name: backupID, in: path, required: true, schema: { type: string } }
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Download one backup file. Administrators only.
|
||||
description: |
|
||||
A SQLite database, served as `application/octet-stream` with
|
||||
`Content-Disposition: attachment` — nothing should ever be tempted to
|
||||
render it inline.
|
||||
|
||||
Restoring is deliberately not an API call. Putting a database back under a
|
||||
running server means stopping every writer, swapping the file and
|
||||
reopening, and a half-done version of that corrupts the thing it was
|
||||
restoring. Stop the container, put the file in place of `verdande.db` in
|
||||
the data volume, delete the `-wal` and `-shm` beside it, and start it
|
||||
again.
|
||||
|
||||
A row whose file has been rotated away answers 404. That is the honest
|
||||
answer to "send me that one".
|
||||
responses:
|
||||
"200":
|
||||
description: The database file.
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: { type: string, format: binary }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/errors:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: What has gone wrong lately. Administrators only.
|
||||
description: |
|
||||
Every 500 the API has answered, newest first, with what the handler was
|
||||
attempting and the error it hit.
|
||||
|
||||
These are written to the database rather than only to the log because the
|
||||
log does not survive. On a Rune the log belongs to the container, and the
|
||||
container is replaced on every restart — so a panel watcher reports "HTTP
|
||||
5xx, twice, at 11:49" long after the line explaining it has gone, which
|
||||
tells an operator that something broke and gives them no way to find out
|
||||
what.
|
||||
|
||||
Kept for thirty days by the nightly sweep: long enough to answer "what
|
||||
happened last week", short enough that a fault loop cannot fill the volume
|
||||
the tasks live on.
|
||||
parameters:
|
||||
- { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
at: { type: string, format: date-time }
|
||||
method: { type: string }
|
||||
path: { type: string }
|
||||
status: { type: integer }
|
||||
what:
|
||||
type: string
|
||||
description: What the handler was attempting, in its own words.
|
||||
example: list projects
|
||||
message: { type: string }
|
||||
user_name:
|
||||
type: string
|
||||
description: |
|
||||
Who was asking, when there was somebody. A fault only
|
||||
one account hits is a different problem from one
|
||||
everybody hits.
|
||||
request_id:
|
||||
type: string
|
||||
description: Ties the row back to the log line, for whoever still has it.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/users:
|
||||
get:
|
||||
tags: [auth]
|
||||
@@ -958,11 +1304,12 @@ paths:
|
||||
The only hard delete in this API. Everything else is soft-deleted with a
|
||||
trash behind it; this is not recoverable.
|
||||
|
||||
What goes is wider than it looks: their projects, every task in those
|
||||
projects, **and every task they wrote anywhere** — `tasks.created_by`
|
||||
cascades, so retiring a colleague's account removes their contributions
|
||||
from shared projects too. Tasks merely assigned to them are unassigned
|
||||
and survive.
|
||||
What goes: their projects, and every task in those projects.
|
||||
|
||||
What stays: everything they wrote in **somebody else's** project.
|
||||
`tasks.created_by` is `ON DELETE SET NULL`, so that work survives and
|
||||
loses its author rather than disappearing with the account. Tasks merely
|
||||
assigned to them are unassigned and survive by the same rule.
|
||||
|
||||
Refused for your own account, and for the last administrator.
|
||||
responses:
|
||||
@@ -1046,6 +1393,37 @@ paths:
|
||||
/api/v1/project-groups/{groupID}:
|
||||
parameters:
|
||||
- { name: groupID, in: path, required: true, schema: { type: string } }
|
||||
get:
|
||||
tags: [projects]
|
||||
summary: The group as a place, with what is in it.
|
||||
description: |
|
||||
A heading can carry a name. A page carries what the group *is*: the
|
||||
sentence saying why these projects are one body of work, the documents
|
||||
that belong to all of them rather than to any one, and the projects
|
||||
themselves.
|
||||
|
||||
The projects come with the group rather than being fetched per row — the
|
||||
page is nothing without them, and two round trips to draw one list is one
|
||||
too many.
|
||||
|
||||
A group that is not yours is not found. That is the rule for every route
|
||||
here, and it is enforced by the lookup rather than by a separate check.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
group: { $ref: "#/components/schemas/ProjectGroup" }
|
||||
projects:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Project" }
|
||||
attachments:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Attachment" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
patch:
|
||||
tags: [projects]
|
||||
summary: Rename a group, or fold it.
|
||||
@@ -1073,6 +1451,41 @@ paths:
|
||||
"204": { description: Deleted. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/project-groups/{groupID}/attachments:
|
||||
parameters:
|
||||
- { name: groupID, in: path, required: true, schema: { type: string } }
|
||||
post:
|
||||
tags: [projects]
|
||||
summary: Put a document on the group itself.
|
||||
description: |
|
||||
A file that belongs to the whole group rather than to any one task in it —
|
||||
the contract that governs all of "Arbejde", not a step in it.
|
||||
|
||||
The same storage as every other attachment: content-addressed by hash, so
|
||||
the path never contains anything the uploader chose, and the same file
|
||||
uploaded twice is stored once. 25 MB maximum.
|
||||
|
||||
Downloading and deleting go through `/attachments/{attachmentID}` like any
|
||||
other. A group's attachment is reachable by its owner and nobody else,
|
||||
because a group belongs to one person and is never shared.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
file: { type: string, format: binary }
|
||||
responses:
|
||||
"201":
|
||||
description: Stored.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Attachment" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
"413": { $ref: "#/components/responses/TooLarge" }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/trash/projects:
|
||||
get:
|
||||
tags: [projects]
|
||||
@@ -2462,6 +2875,14 @@ components:
|
||||
locale: { type: string, enum: [da, en] }
|
||||
is_admin: { type: boolean }
|
||||
totp_enabled: { type: boolean }
|
||||
sidebar_collapsed:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: |
|
||||
The sidebar headings this person has folded away. Always an array,
|
||||
never null: a client that has to check for both an absent array and an
|
||||
empty one will one day check for only one of them.
|
||||
example: ["labels"]
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
RecoveryCodes:
|
||||
@@ -2496,9 +2917,60 @@ components:
|
||||
task_count:
|
||||
type: integer
|
||||
description: |
|
||||
Everything a delete would destroy: tasks in projects they own, plus
|
||||
every task they wrote anywhere. Not the same as "their tasks", and
|
||||
the difference is the whole point of showing it.
|
||||
What a delete would destroy: the tasks in the projects they own,
|
||||
which go with those projects.
|
||||
authored_elsewhere:
|
||||
type: integer
|
||||
description: |
|
||||
What a delete would leave behind: tasks they wrote in projects other
|
||||
people own. Those survive without an author. A separate number from
|
||||
`task_count` because it is a separate sentence — one is what
|
||||
disappears, the other is what stays unattributed.
|
||||
|
||||
GmailClient:
|
||||
type: object
|
||||
properties:
|
||||
client_id: { type: string }
|
||||
has_secret:
|
||||
type: boolean
|
||||
description: Whether a secret is stored. The secret itself is never sent back.
|
||||
from_env:
|
||||
type: boolean
|
||||
description: |
|
||||
The deployment set this, so the stored pair is ignored and the fields
|
||||
are not worth editing.
|
||||
redirect_uri:
|
||||
type: string
|
||||
description: |
|
||||
What has to be registered with Google, spelled out. Derived from
|
||||
`VERDANDE_BASE_URL` and matched exactly — it is the single most likely
|
||||
thing to be wrong, and the error Google returns for it names neither
|
||||
value. Google rejects private IP addresses over http, so a LAN address
|
||||
will not do: it has to be https, or `http://localhost`.
|
||||
example: https://verdande.example.com/oauth/gmail/callback
|
||||
|
||||
BackupRun:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
started_at: { type: string, format: date-time }
|
||||
finished_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: |
|
||||
Absent while a run is still going, and on one the process died in the
|
||||
middle of — which is the same shape from outside, and honestly so: a
|
||||
run that was interrupted never finished.
|
||||
size_bytes: { type: integer }
|
||||
error:
|
||||
type: string
|
||||
description: Present only on a run that failed, in the words it failed with.
|
||||
present:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether the file is still on disk. Rotation keeps the most recent
|
||||
fourteen, so an older row is a record with nothing behind it and its
|
||||
download answers 404.
|
||||
|
||||
PendingInvite:
|
||||
type: object
|
||||
@@ -2555,7 +3027,12 @@ components:
|
||||
items: { type: string }
|
||||
completed: { type: boolean }
|
||||
completed_at: { type: string, format: date-time }
|
||||
created_by: { type: string }
|
||||
created_by:
|
||||
type: string
|
||||
description: |
|
||||
Empty when the author's account has been deleted. The task outlives
|
||||
the person who wrote it.
|
||||
|
||||
sort_order: { type: number }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
@@ -2656,6 +3133,12 @@ components:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
color: { $ref: "#/components/schemas/Color" }
|
||||
description:
|
||||
type: string
|
||||
description: |
|
||||
What the group is, in the owner's words. A heading carries a name; a
|
||||
page carries the sentence that says why these projects are one body of
|
||||
work. Empty until somebody writes one.
|
||||
collapsed: { type: boolean }
|
||||
sort_order: { type: number }
|
||||
|
||||
@@ -2664,6 +3147,10 @@ components:
|
||||
properties:
|
||||
name: { type: string }
|
||||
color: { $ref: "#/components/schemas/Color" }
|
||||
description:
|
||||
type: string
|
||||
maxLength: 4000
|
||||
description: What the group is, in the owner's words.
|
||||
collapsed: { type: boolean }
|
||||
|
||||
Color:
|
||||
|
||||
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 163 KiB |
@@ -0,0 +1,111 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// What has happened on this server, across every project.
|
||||
//
|
||||
// The per-project history under /projects/{id}/activity is the same rows read by
|
||||
// the people who work in that project. This is the administrator's version of the
|
||||
// question, and it is a different one: an administrator is not necessarily a member
|
||||
// of the project they need to look into, and "which account did that" cannot be
|
||||
// asked one project at a time.
|
||||
//
|
||||
// It sits beside the error log for a reason. That one is the half about what broke;
|
||||
// this is the half about what was done, and an instance had only ever had the first.
|
||||
|
||||
type auditEntryJSON struct {
|
||||
ID string `json:"id"`
|
||||
At string `json:"at"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ProjectName string `json:"project_name"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
// UserID and UserName are absent when the account has been deleted. The row
|
||||
// stays — an audit log that lost its rows when somebody left would be missing
|
||||
// exactly the part worth keeping.
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
Event string `json:"event"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
f := store.AuditFilter{
|
||||
UserID: q.Get("user_id"),
|
||||
ProjectID: q.Get("project_id"),
|
||||
Event: q.Get("event"),
|
||||
Limit: parseLimit(q.Get("limit"), 100, 500),
|
||||
}
|
||||
if cursor := q.Get("before"); cursor != "" {
|
||||
at, id, ok := parseAuditCursor(cursor)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||||
"before must be <unix>:<id>, as next_cursor returns it")
|
||||
return
|
||||
}
|
||||
f.BeforeAt, f.BeforeID = at, id
|
||||
}
|
||||
|
||||
entries, err := s.db.ListAuditLog(r.Context(), f)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list audit log", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]auditEntryJSON, 0, len(entries))
|
||||
for _, a := range entries {
|
||||
out = append(out, auditEntryJSON{
|
||||
ID: a.ID, At: a.CreatedAt.Format(time.RFC3339),
|
||||
ProjectID: a.ProjectID, ProjectName: a.ProjectName, TaskID: a.TaskID,
|
||||
UserID: a.UserID, UserName: a.UserName, Event: a.Event, Payload: a.Payload,
|
||||
})
|
||||
}
|
||||
|
||||
body := map[string]any{"activity": out}
|
||||
// A cursor only when the page was full. A short page is the end of the log, and
|
||||
// a cursor there invites one more request that can only come back empty.
|
||||
if len(entries) == f.Limit && f.Limit > 0 {
|
||||
last := entries[len(entries)-1]
|
||||
body["next_cursor"] = strconv.FormatInt(last.CreatedAt.Unix(), 10) + ":" + last.ID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// handleAuditEvents is what the event filter is drawn from: the names that occur,
|
||||
// with counts. Offered rather than typed — "member.role_changed" is an internal
|
||||
// vocabulary, and a text field for it is a field that returns nothing until you
|
||||
// have read the source.
|
||||
func (s *Server) handleAuditEvents(w http.ResponseWriter, r *http.Request) {
|
||||
events, err := s.db.AuditEvents(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, r, "list audit events", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(events))
|
||||
for _, e := range events {
|
||||
out = append(out, map[string]any{"event": e.Event, "count": e.Count})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"events": out})
|
||||
}
|
||||
|
||||
// parseAuditCursor reads "<unix>:<id>" — the pair the keyset walk needs. Both
|
||||
// halves are required: a cursor carrying only a timestamp cannot separate two rows
|
||||
// written in the same second, which is the case it exists for.
|
||||
func parseAuditCursor(s string) (int64, string, bool) {
|
||||
at, id, found := strings.Cut(s, ":")
|
||||
if !found || id == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
unix, err := strconv.ParseInt(at, 10, 64)
|
||||
if err != nil || unix <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
return unix, id, true
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The point of the instance-wide log is that it crosses projects the caller is not
|
||||
// a member of. The per-project endpoint cannot answer this question, and an
|
||||
// administrator asking "what has happened on this server" was previously told to
|
||||
// go and look in each project they happen to belong to.
|
||||
func TestTheAuditLogCrossesProjectsTheAdminIsNotInAllOfThem(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
// A project the administrator is not a member of.
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres eget"})
|
||||
theirID := theirs["id"].(string)
|
||||
other.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "noget i deres eget", "project_id": theirID,
|
||||
})
|
||||
|
||||
// The administrator cannot read that project's own history at all.
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/projects/"+theirID+"/activity", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("a project the admin is not in: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
// The instance log has it.
|
||||
resp, body := ts.do(t, "GET", "/api/v1/activity", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("audit log: status %d", resp.StatusCode)
|
||||
}
|
||||
entries, _ := body["activity"].([]any)
|
||||
var found map[string]any
|
||||
for _, raw := range entries {
|
||||
e := raw.(map[string]any)
|
||||
if e["project_id"] == theirID && e["event"] == "task.created" {
|
||||
found = e
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("nothing from a project the admin is not in: %v", entries)
|
||||
}
|
||||
if found["project_name"] != "Deres eget" {
|
||||
t.Errorf("project_name = %v — a row that does not name its project says nothing",
|
||||
found["project_name"])
|
||||
}
|
||||
if found["user_name"] != "Anden" {
|
||||
t.Errorf("user_name = %v", found["user_name"])
|
||||
}
|
||||
}
|
||||
|
||||
// Administrators only, and sessions only. An audit log readable with a bearer token
|
||||
// would let a stolen token read the record of the theft — and of everything else on
|
||||
// the instance.
|
||||
func TestTheAuditLogIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} {
|
||||
if resp, _ := other.do(t, "GET", path, nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
for _, path := range []string{"/api/v1/activity", "/api/v1/activity/events"} {
|
||||
req, _ := http.NewRequest("GET", ts.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s with an admin's API token: status %d, want 403", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cursor is keyed on (created_at, id) because the log is written in whole
|
||||
// seconds. A page boundary that landed inside a busy second would, with a
|
||||
// timestamp-only cursor, either repeat those rows or skip them — and an audit log
|
||||
// that skips rows is worse than no audit log.
|
||||
func TestTheAuditLogPagesWithoutRepeatingOrSkipping(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Meget"})
|
||||
projectID := project["id"].(string)
|
||||
// Written as fast as the loop goes, so they share timestamps.
|
||||
const written = 25
|
||||
for i := range written {
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": fmt.Sprintf("opgave %d", i), "project_id": projectID,
|
||||
})
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
cursor := ""
|
||||
for page := 0; page < 20; page++ {
|
||||
path := "/api/v1/activity?limit=4"
|
||||
if cursor != "" {
|
||||
path += "&before=" + cursor
|
||||
}
|
||||
resp, body := ts.do(t, "GET", path, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("page %d: status %d", page, resp.StatusCode)
|
||||
}
|
||||
entries, _ := body["activity"].([]any)
|
||||
for _, raw := range entries {
|
||||
id := raw.(map[string]any)["id"].(string)
|
||||
if seen[id] {
|
||||
t.Fatalf("row %s came back on two pages", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
next, _ := body["next_cursor"].(string)
|
||||
if next == "" {
|
||||
break
|
||||
}
|
||||
if next == cursor {
|
||||
t.Fatalf("the cursor did not move: %s", next)
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
|
||||
// Everything that was written is in the walk exactly once: the task creations,
|
||||
// plus the project creation.
|
||||
if len(seen) != written+1 {
|
||||
t.Errorf("the walk saw %d rows, want %d — a page boundary lost some",
|
||||
len(seen), written+1)
|
||||
}
|
||||
}
|
||||
|
||||
// The filters, and the list the event filter is drawn from.
|
||||
func TestTheAuditLogFiltersAndListsItsEvents(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||
mineID := mine["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "min", "project_id": mineID})
|
||||
|
||||
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Deres"})
|
||||
theirID := theirs["id"].(string)
|
||||
other.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "deres", "project_id": theirID})
|
||||
|
||||
_, byUser := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID, nil)
|
||||
for _, raw := range byUser["activity"].([]any) {
|
||||
if got := raw.(map[string]any)["user_id"]; got != otherUser.ID {
|
||||
t.Errorf("user_id filter returned a row by %v", got)
|
||||
}
|
||||
}
|
||||
if len(byUser["activity"].([]any)) == 0 {
|
||||
t.Error("the user filter returned nothing")
|
||||
}
|
||||
|
||||
_, byProject := ts.do(t, "GET", "/api/v1/activity?project_id="+mineID, nil)
|
||||
for _, raw := range byProject["activity"].([]any) {
|
||||
if got := raw.(map[string]any)["project_id"]; got != mineID {
|
||||
t.Errorf("project_id filter returned a row from %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
_, byEvent := ts.do(t, "GET", "/api/v1/activity?event=project.created", nil)
|
||||
rows := byEvent["activity"].([]any)
|
||||
if len(rows) != 2 {
|
||||
t.Errorf("event filter found %d project.created rows, want 2", len(rows))
|
||||
}
|
||||
for _, raw := range rows {
|
||||
if got := raw.(map[string]any)["event"]; got != "project.created" {
|
||||
t.Errorf("event filter returned %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// And the vocabulary the filter is built from.
|
||||
_, events := ts.do(t, "GET", "/api/v1/activity/events", nil)
|
||||
counts := map[string]float64{}
|
||||
for _, raw := range events["events"].([]any) {
|
||||
e := raw.(map[string]any)
|
||||
counts[e["event"].(string)] = e["count"].(float64)
|
||||
}
|
||||
if counts["project.created"] != 2 || counts["task.created"] != 2 {
|
||||
t.Errorf("event counts = %v", counts)
|
||||
}
|
||||
|
||||
// A malformed cursor is a 400, not a silent first page: paging that quietly
|
||||
// restarts is how an audit walk reads the same rows forever.
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/activity?before=igår", nil); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a malformed cursor: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A deleted account leaves its trail behind. This is the reason migration 0009
|
||||
// exists: an audit log that lost its rows when somebody left would be missing
|
||||
// exactly the part worth keeping.
|
||||
func TestTheAuditLogOutlivesTheAccountItDescribes(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
otherUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// In a project that outlives them, so the row is not carried off by the
|
||||
// project cascade instead.
|
||||
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
||||
sharedID := mine["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
|
||||
"email": "[email protected]", "role": "editor",
|
||||
})
|
||||
other.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "deres bidrag", "project_id": sharedID,
|
||||
})
|
||||
|
||||
_, before := ts.do(t, "GET", "/api/v1/activity?user_id="+otherUser.ID+"&event=task.created", nil)
|
||||
if len(before["activity"].([]any)) != 1 {
|
||||
t.Fatalf("expected one row before the delete: %v", before["activity"])
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/activity?project_id="+sharedID+"&event=task.created", nil)
|
||||
rows := after["activity"].([]any)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("the row went with the account: %v", rows)
|
||||
}
|
||||
row := rows[0].(map[string]any)
|
||||
if _, ok := row["user_id"]; ok {
|
||||
t.Errorf("user_id is still there after the account went: %v", row["user_id"])
|
||||
}
|
||||
if _, ok := row["user_name"]; ok {
|
||||
t.Errorf("user_name is still there after the account went: %v", row["user_name"])
|
||||
}
|
||||
|
||||
// And the project's own history still shows it, rather than dropping the row
|
||||
// on an inner join to a user that is gone.
|
||||
_, project := ts.do(t, "GET", "/api/v1/projects/"+sharedID+"/activity", nil)
|
||||
var kept int
|
||||
for _, raw := range project["activity"].([]any) {
|
||||
if raw.(map[string]any)["event"] == "task.created" {
|
||||
kept++
|
||||
}
|
||||
}
|
||||
if kept != 1 {
|
||||
t.Errorf("the project history dropped the authorless row: %d", kept)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
@@ -25,17 +27,62 @@ type userJSON struct {
|
||||
Locale string `json:"locale"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
TOTPEnabled bool `json:"totp_enabled"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// SidebarCollapsed is which sidebar headings this person has folded away. On
|
||||
// the account rather than in the browser, because folding one is a statement
|
||||
// about the work and not about the screen — the sidebar's width is the
|
||||
// opposite case and stays in localStorage.
|
||||
SidebarCollapsed []string `json:"sidebar_collapsed"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toUserJSON(u *store.User) userJSON {
|
||||
collapsed := u.SidebarCollapsed
|
||||
// Never null in the response: a client that has to check for both an absent
|
||||
// array and an empty one will one day check for only one of them.
|
||||
if collapsed == nil {
|
||||
collapsed = []string{}
|
||||
}
|
||||
return userJSON{
|
||||
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
||||
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
||||
TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// handleSetSidebarSections records which sidebar headings are folded away.
|
||||
//
|
||||
// Its own route rather than a field on the profile: that one backs a form with a
|
||||
// save button and validates three fields together, and this writes on every click
|
||||
// of a chevron. Sharing the path would mean a fold sending a name and a timezone
|
||||
// it was never asked to change — and a validation error on one of those failing a
|
||||
// fold.
|
||||
//
|
||||
// The keys are the sidebar's and are not checked here. An unknown one costs
|
||||
// nothing: the sidebar folds the headings it recognises and ignores the rest, so
|
||||
// a heading that is renamed or removed leaves a harmless entry rather than a
|
||||
// migration.
|
||||
func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Sections []string `json:"sections"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
// A cap, because this is a list the client decides the length of. Twenty is far
|
||||
// more headings than the sidebar has ever had.
|
||||
if len(req.Sections) > 20 {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many sections")
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.SetSidebarCollapsed(r.Context(), user.ID, req.Sections); err != nil {
|
||||
s.storeError(w, r, "set sidebar sections", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- sign in -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -88,7 +135,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled)
|
||||
if err != nil {
|
||||
s.internal(w, "create session", err)
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
@@ -130,7 +177,7 @@ func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil {
|
||||
s.internal(w, "promote session", err)
|
||||
s.internal(w, r, "promote session", err)
|
||||
return
|
||||
}
|
||||
// The cookie is reissued so its lifetime matches the session's new one.
|
||||
@@ -210,13 +257,13 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil {
|
||||
s.storeError(w, "update profile", err)
|
||||
s.storeError(w, r, "update profile", err)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.db.UserByID(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "reload user", err)
|
||||
s.internal(w, r, "reload user", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toUserJSON(updated))
|
||||
@@ -263,7 +310,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -277,14 +324,14 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
"an account already exists for that email address")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create user", err)
|
||||
s.internal(w, r, "create user", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Accepting the invite is what adds the membership and burns the token, in one
|
||||
// transaction: a signup that half-succeeded would leave a usable invite behind.
|
||||
if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil {
|
||||
s.internal(w, "accept invite", err)
|
||||
s.internal(w, r, "accept invite", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -328,7 +375,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -341,7 +388,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create first admin", err)
|
||||
s.internal(w, r, "create first admin", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -355,7 +402,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.UserCount(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "count users", err)
|
||||
s.internal(w, r, "count users", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
|
||||
@@ -364,7 +411,7 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
|
||||
token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false)
|
||||
if err != nil {
|
||||
s.internal(w, "create session", err)
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, s.cfg.SessionTTL)
|
||||
@@ -393,7 +440,7 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil {
|
||||
token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL)
|
||||
if err != nil {
|
||||
s.internal(w, "create password reset", err)
|
||||
s.internal(w, r, "create password reset", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/reset?token=" + token
|
||||
@@ -431,13 +478,13 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
// Every session ends: a password reset is what somebody does when they think
|
||||
// another person has been in their account.
|
||||
if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil {
|
||||
s.internal(w, "update password", err)
|
||||
s.internal(w, r, "update password", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -469,7 +516,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
s.internal(w, "hash password", err)
|
||||
s.internal(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
keep := ""
|
||||
@@ -477,7 +524,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
keep = session.ID
|
||||
}
|
||||
if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil {
|
||||
s.internal(w, "update password", err)
|
||||
s.internal(w, r, "update password", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -520,7 +567,27 @@ func inboxName(locale string) string {
|
||||
// internal logs the real error and tells the client nothing about it. The detail
|
||||
// belongs in the log, where the operator can see it; in the response it would be a
|
||||
// description of the server's internals handed to whoever asked.
|
||||
func (s *Server) internal(w http.ResponseWriter, what string, err error) {
|
||||
//
|
||||
// It is also written to the database. On a Rune the log is the container's, and
|
||||
// every restart begins a new one — so the panel reports "HTTP 5xx, twice, at
|
||||
// 11:49" long after the line explaining it has gone, which tells an operator that
|
||||
// something broke and gives them no way to find out what. The row outlives the
|
||||
// container; Indstillinger → Fejl is where it is read.
|
||||
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||
s.log.Error(what, "err", err)
|
||||
|
||||
userID := ""
|
||||
if u := userFrom(r.Context()); u != nil {
|
||||
userID = u.ID
|
||||
}
|
||||
// Best effort, and deliberately not checked: this is the path that is already
|
||||
// reporting a failure, and a diagnostic that can fail the request would turn
|
||||
// one broken screen into two.
|
||||
s.db.RecordError(r.Context(), store.ServerError{
|
||||
Method: r.Method, Path: r.URL.Path, Status: http.StatusInternalServerError,
|
||||
What: what, Message: err.Error(), UserID: userID,
|
||||
RequestID: middleware.GetReqID(r.Context()),
|
||||
})
|
||||
|
||||
writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/jobs"
|
||||
)
|
||||
|
||||
// The nightly backup, made visible.
|
||||
//
|
||||
// It has run since the beginning and nothing showed it. A backup nobody can see is
|
||||
// a backup nobody knows has been failing — and the failure mode is quiet: a volume
|
||||
// that went read-only, a disk that filled, a container that has not been up at
|
||||
// midnight for a week. The first time anybody finds out is the day they need one.
|
||||
//
|
||||
// Administrators only, and behind a session as well as the admin check. A backup
|
||||
// file is a complete copy of the database, so downloading one is downloading
|
||||
// everybody's data; a leaked API token must not be able to.
|
||||
|
||||
type backupRunJSON struct {
|
||||
ID string `json:"id"`
|
||||
Started string `json:"started_at"`
|
||||
// Finished is absent while a backup is still running, and on one that was
|
||||
// interrupted — which is the same shape from outside and honestly so: a run
|
||||
// the process died in the middle of never finished.
|
||||
Finished string `json:"finished_at,omitempty"`
|
||||
Size int64 `json:"size_bytes"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// Present says the file is still on disk. Rotation keeps the most recent
|
||||
// fourteen, so an older row is a true record of a backup that was made and a
|
||||
// file that is no longer there — and a download link for it would 404.
|
||||
Present bool `json:"present"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||
runs, err := s.db.ListBackupRuns(r.Context(), parseLimit(r.URL.Query().Get("limit"), 30, 100))
|
||||
if err != nil {
|
||||
s.internal(w, r, "list backups", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]backupRunJSON, 0, len(runs))
|
||||
for _, b := range runs {
|
||||
j := backupRunJSON{
|
||||
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
|
||||
Size: b.SizeBytes, Error: b.Error,
|
||||
}
|
||||
if b.FinishedAt != nil {
|
||||
j.Finished = b.FinishedAt.Format(time.RFC3339)
|
||||
}
|
||||
if b.Path != "" {
|
||||
if _, err := os.Stat(b.Path); err == nil {
|
||||
j.Present = true
|
||||
}
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"backups": out})
|
||||
}
|
||||
|
||||
// handleRunBackup takes one now.
|
||||
//
|
||||
// The same function the nightly job runs, without the "has one run today" check.
|
||||
// Waiting until tonight to learn whether backups work at all is how somebody finds
|
||||
// out on the day they need one — and it is the only way to get a snapshot from
|
||||
// *before* something you are about to do.
|
||||
//
|
||||
// Synchronous. A backup of a homelab database is a second or two, and a button
|
||||
// that returns immediately and tells you nothing is a button you press twice.
|
||||
func (s *Server) handleRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if err := jobs.Backup(r.Context(), s.cfg, s.db, s.log); err != nil {
|
||||
s.internal(w, r, "run backup", err)
|
||||
return
|
||||
}
|
||||
runs, err := s.db.ListBackupRuns(r.Context(), 1)
|
||||
if err != nil || len(runs) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
b := runs[0]
|
||||
j := backupRunJSON{
|
||||
ID: b.ID, Started: b.StartedAt.Format(time.RFC3339),
|
||||
Size: b.SizeBytes, Error: b.Error, Present: true,
|
||||
}
|
||||
if b.FinishedAt != nil {
|
||||
j.Finished = b.FinishedAt.Format(time.RFC3339)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, j)
|
||||
}
|
||||
|
||||
// handleDownloadBackup sends one backup file.
|
||||
//
|
||||
// The path comes from the row rather than from the request, and is then checked to
|
||||
// be inside the backups directory anyway. The row is written by this process and
|
||||
// should always be safe; the check is there because "should always" is how a path
|
||||
// traversal gets shipped, and the thing on the other side of it is every account's
|
||||
// data in one file.
|
||||
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||
runs, err := s.db.ListBackupRuns(r.Context(), 100)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list backups", err)
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "backupID")
|
||||
var path string
|
||||
for _, b := range runs {
|
||||
if b.ID == id {
|
||||
path = b.Path
|
||||
break
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
dir, err := filepath.Abs(s.cfg.BackupsDir())
|
||||
if err != nil {
|
||||
s.internal(w, r, "backups dir", err)
|
||||
return
|
||||
}
|
||||
full, err := filepath.Abs(path)
|
||||
if err != nil || !strings.HasPrefix(full, dir+string(os.PathSeparator)) {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
file, err := os.Open(full)
|
||||
if err != nil {
|
||||
// Rotation keeps the most recent fourteen, so a row older than that is a
|
||||
// true record with no file behind it. Not an error — a 404 is the honest
|
||||
// answer to "send me that one".
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
s.internal(w, r, "stat backup", err)
|
||||
return
|
||||
}
|
||||
|
||||
// The same treatment attachments get, and for a stronger reason: this is a
|
||||
// SQLite database, and nothing should ever be tempted to render it inline.
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf("attachment; filename=%q", filepath.Base(full)))
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.ServeContent(w, r, "", info.ModTime(), file)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The backup panel exists because the job was invisible, so the thing to check is
|
||||
// that it can be seen and asked for — and that the file it hands out is guarded
|
||||
// like what it is, which is every account's data in one download.
|
||||
func TestABackupCanBeTakenListedAndDownloaded(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Nothing yet, and that is a state the list has to survive rather than a case
|
||||
// nobody hits: it is what a fresh instance looks like.
|
||||
_, empty := ts.do(t, "GET", "/api/v1/backups", nil)
|
||||
if got, _ := empty["backups"].([]any); len(got) != 0 {
|
||||
t.Fatalf("a fresh instance has %d backups", len(got))
|
||||
}
|
||||
|
||||
resp, made := ts.do(t, "POST", "/api/v1/backups", nil)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("run backup: status %d, body %v", resp.StatusCode, made)
|
||||
}
|
||||
id, _ := made["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("the new run has no id")
|
||||
}
|
||||
if size, _ := made["size_bytes"].(float64); size <= 0 {
|
||||
t.Errorf("size_bytes = %v — a backup of a database with rows in it is not empty", made["size_bytes"])
|
||||
}
|
||||
if made["error"] != nil {
|
||||
t.Errorf("the backup reported an error: %v", made["error"])
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/backups", nil)
|
||||
rows, _ := listed["backups"].([]any)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("listed %d backups, want 1", len(rows))
|
||||
}
|
||||
row := rows[0].(map[string]any)
|
||||
if row["id"] != id {
|
||||
t.Errorf("the listed run is not the one that was made")
|
||||
}
|
||||
if row["present"] != true {
|
||||
t.Error("present = false on a backup that was just written")
|
||||
}
|
||||
|
||||
// And the file itself, which must arrive as a download rather than as
|
||||
// something a browser might try to render.
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/backups/"+id, nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
dl, err := ts.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dl.Body.Close()
|
||||
if dl.StatusCode != http.StatusOK {
|
||||
t.Fatalf("download: status %d", dl.StatusCode)
|
||||
}
|
||||
if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" {
|
||||
t.Errorf("Content-Type = %q, want application/octet-stream", ct)
|
||||
}
|
||||
if cd := dl.Header.Get("Content-Disposition"); cd == "" || cd[:10] != "attachment" {
|
||||
t.Errorf("Content-Disposition = %q, want an attachment", cd)
|
||||
}
|
||||
|
||||
// An id that is not a backup is a 404, not a path to try opening.
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/backups/../../etc/passwd", nil); resp.StatusCode == http.StatusOK {
|
||||
t.Error("a traversal in the id was served")
|
||||
}
|
||||
if resp, _ := ts.do(t, "GET", "/api/v1/backups/nogetsomhelst", nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("an unknown id: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Administrators only, and sessions only. A backup is a complete copy of the
|
||||
// database — a leaked token that could download one turns a stolen credential into
|
||||
// a copy of everybody's data.
|
||||
func TestBackupsAreAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/backups"},
|
||||
{"POST", "/api/v1/backups"},
|
||||
{"GET", "/api/v1/backups/whichever"},
|
||||
} {
|
||||
if resp, _ := other.do(t, c.method, c.path, nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s as an ordinary user: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/backups"},
|
||||
{"POST", "/api/v1/backups"},
|
||||
} {
|
||||
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s with an admin's API token: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func (s *Server) handleCalDAVHome(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||
if err != nil {
|
||||
s.internal(w, "caldav home", err)
|
||||
s.internal(w, r, "caldav home", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func (s *Server) handleCalDAVCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Depth") != "0" {
|
||||
tasks, err := s.caldavTasks(r, user, projectID)
|
||||
if err != nil {
|
||||
s.internal(w, "caldav tasks", err)
|
||||
s.internal(w, r, "caldav tasks", err)
|
||||
return
|
||||
}
|
||||
for _, t := range tasks {
|
||||
@@ -157,7 +157,7 @@ func (s *Server) handleCalDAVReport(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tasks, err := s.caldavTasks(r, user, projectID)
|
||||
if err != nil {
|
||||
s.internal(w, "caldav report", err)
|
||||
s.internal(w, r, "caldav report", err)
|
||||
return
|
||||
}
|
||||
byID := map[string]store.Task{}
|
||||
@@ -240,7 +240,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
||||
update.DueDatetime = parsed.DueDatetime
|
||||
|
||||
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, update); err != nil {
|
||||
s.internal(w, "caldav update", err)
|
||||
s.internal(w, r, "caldav update", err)
|
||||
return
|
||||
}
|
||||
if parsed.Completed && !existing.Completed() {
|
||||
@@ -275,7 +275,7 @@ func (s *Server) handleCalDAVPut(w http.ResponseWriter, r *http.Request) {
|
||||
task.Content = "(uden titel)"
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, nil); err != nil {
|
||||
s.internal(w, "caldav create", err)
|
||||
s.internal(w, r, "caldav create", err)
|
||||
return
|
||||
}
|
||||
s.publish(projectID, "task.created", toTaskJSON(*task))
|
||||
@@ -294,7 +294,7 @@ func (s *Server) handleCalDAVDelete(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||
s.internal(w, "caldav delete", err)
|
||||
s.internal(w, r, "caldav delete", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -68,7 +68,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
comments, err := s.db.ListComments(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list comments", err)
|
||||
s.internal(w, r, "list comments", err)
|
||||
return
|
||||
}
|
||||
out := make([]commentJSON, 0, len(comments))
|
||||
@@ -78,7 +78,7 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
files, err := s.db.ListTaskAttachments(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list attachments", err)
|
||||
s.internal(w, r, "list attachments", err)
|
||||
return
|
||||
}
|
||||
direct := make([]attachmentJSON, 0, len(files))
|
||||
@@ -119,7 +119,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
comment, err := s.db.CreateComment(r.Context(), taskID, user.ID, body)
|
||||
if err != nil {
|
||||
s.internal(w, "create comment", err)
|
||||
s.internal(w, r, "create comment", err)
|
||||
return
|
||||
}
|
||||
comment.UserName, comment.UserColor = user.Name, user.AvatarColor
|
||||
@@ -149,7 +149,7 @@ func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
err := s.db.UpdateComment(r.Context(), chi.URLParam(r, "commentID"), userFrom(r.Context()).ID, body)
|
||||
if err != nil {
|
||||
s.storeError(w, "update comment", err)
|
||||
s.storeError(w, r, "update comment", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -161,7 +161,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
taskID, err := s.db.CommentTask(r.Context(), commentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "comment task", err)
|
||||
s.storeError(w, r, "comment task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
@@ -171,7 +171,7 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.DeleteComment(r.Context(), commentID, user.ID, role.CanManage()); err != nil {
|
||||
s.storeError(w, "delete comment", err)
|
||||
s.storeError(w, r, "delete comment", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -219,7 +219,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
stored, size, err := s.storeUpload(file)
|
||||
if err != nil {
|
||||
s.internal(w, "store upload", err)
|
||||
s.internal(w, r, "store upload", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ func (s *Server) handleUploadAttachment(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
s.internal(w, "record attachment", err)
|
||||
s.internal(w, r, "record attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -300,23 +300,55 @@ func (s *Server) storeUpload(file io.Reader) (string, int64, error) {
|
||||
return rel, size, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
// reachAttachment answers whether this person may see an attachment at all, and
|
||||
// whether they may change it.
|
||||
//
|
||||
// An attachment hangs on exactly one of three things, and each has its own idea of
|
||||
// who may reach it. A task's is governed by its project's roles, directly or
|
||||
// through the comment it hangs on. A group's is governed by nothing but ownership:
|
||||
// a group belongs to one person, is never shared, and a group that is not yours is
|
||||
// not found — the same rule the rest of the group routes use.
|
||||
//
|
||||
// Both callers below used to resolve straight to a task, so a group's attachment
|
||||
// answered 404 for its own owner.
|
||||
func (s *Server) reachAttachment(r *http.Request, attachmentID string) (canEdit bool, err error) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
a, err := s.db.GetAttachment(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if a.GroupID != "" {
|
||||
if _, err := s.db.GetProjectGroup(r.Context(), a.GroupID, user.ID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Nobody else can reach it, so whoever can, can change it.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "attachment task", err)
|
||||
return
|
||||
return false, err
|
||||
}
|
||||
if _, err := store.TaskRole(r.Context(), s.db, taskID, user.ID); err != nil {
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return role.CanEdit(), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
|
||||
if _, err := s.reachAttachment(r, attachmentID); err != nil {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
a, err := s.db.GetAttachment(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get attachment", err)
|
||||
s.storeError(w, r, "get attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -354,22 +386,16 @@ func (s *Server) handleDownloadAttachment(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (s *Server) handleDeleteAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attachmentID := chi.URLParam(r, "attachmentID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
taskID, err := s.db.AttachmentTask(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "attachment task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
if err != nil || !role.CanEdit() {
|
||||
canEdit, err := s.reachAttachment(r, attachmentID)
|
||||
if err != nil || !canEdit {
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
return
|
||||
}
|
||||
|
||||
path, err := s.db.DeleteAttachment(r.Context(), attachmentID)
|
||||
if err != nil {
|
||||
s.storeError(w, "delete attachment", err)
|
||||
s.storeError(w, r, "delete attachment", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What has gone wrong lately, kept where a restart cannot take it.
|
||||
//
|
||||
// The panel's watcher reports "HTTP 5xx, twice, at 11:49" — which is enough to
|
||||
// know something broke and nothing at all to act on. The line that explains it is
|
||||
// in the container's log, and a Rune's container is replaced on every restart, so
|
||||
// by the time anybody looks the explanation is gone. These rows are the same
|
||||
// information written somewhere that survives.
|
||||
|
||||
type serverErrorJSON struct {
|
||||
ID string `json:"id"`
|
||||
At string `json:"at"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Status int `json:"status"`
|
||||
// What the handler was doing, in its own words — "list projects", "move task".
|
||||
What string `json:"what"`
|
||||
Message string `json:"message"`
|
||||
// Who was asking, when there was somebody: a fault only one account hits is a
|
||||
// different problem from one everybody hits.
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
// RequestID ties the row back to the log line, for whoever still has it.
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListErrors(w http.ResponseWriter, r *http.Request) {
|
||||
errs, err := s.db.ListErrors(r.Context(), parseLimit(r.URL.Query().Get("limit"), 100, 500))
|
||||
if err != nil {
|
||||
s.internal(w, r, "list errors", err)
|
||||
return
|
||||
}
|
||||
out := make([]serverErrorJSON, 0, len(errs))
|
||||
for _, e := range errs {
|
||||
out = append(out, serverErrorJSON{
|
||||
ID: e.ID, At: e.At.Format(time.RFC3339), Method: e.Method, Path: e.Path,
|
||||
Status: e.Status, What: e.What, Message: e.Message,
|
||||
UserName: e.UserName, RequestID: e.RequestID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"errors": out})
|
||||
}
|
||||
@@ -36,13 +36,13 @@ func (s *Server) handleICSFeed(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "ics feed", err)
|
||||
s.internal(w, r, "ics feed", err)
|
||||
return
|
||||
}
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, "ics feed projects", err)
|
||||
s.internal(w, r, "ics feed projects", err)
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
@@ -89,7 +89,7 @@ func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, err := s.db.EnsureICSToken(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ics token", err)
|
||||
s.internal(w, r, "ics token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
||||
@@ -103,11 +103,11 @@ func (s *Server) handleRotateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.internal(w, "generate feed token", err)
|
||||
s.internal(w, r, "generate feed token", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetICSToken(r.Context(), user.ID, token); err != nil {
|
||||
s.internal(w, "set feed token", err)
|
||||
s.internal(w, r, "set feed token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, feedResponse{URL: s.cfg.BaseURL + "/ics/" + token + ".ics"})
|
||||
@@ -151,7 +151,7 @@ func (s *Server) handleListReminders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
reminders, err := s.db.ListReminders(r.Context(), taskID)
|
||||
if err != nil {
|
||||
s.internal(w, "list reminders", err)
|
||||
s.internal(w, r, "list reminders", err)
|
||||
return
|
||||
}
|
||||
out := make([]reminderJSON, 0, len(reminders))
|
||||
@@ -197,7 +197,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rem, err := s.db.CreateReminder(r.Context(), taskID, user.ID, at, req.OffsetMin)
|
||||
if err != nil {
|
||||
s.internal(w, "create reminder", err)
|
||||
s.internal(w, r, "create reminder", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func (s *Server) handleCreateReminder(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteReminder(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteReminder(r.Context(), chi.URLParam(r, "reminderID"), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "delete reminder", err)
|
||||
s.storeError(w, r, "delete reminder", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -238,7 +238,7 @@ func toTemplateJSON(t store.Template) templateJSON {
|
||||
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
templates, err := s.db.ListTemplates(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list templates", err)
|
||||
s.internal(w, r, "list templates", err)
|
||||
return
|
||||
}
|
||||
out := make([]templateJSON, 0, len(templates))
|
||||
@@ -273,7 +273,7 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
tpl, err := s.db.SaveProjectAsTemplate(r.Context(), req.ProjectID, user.ID,
|
||||
strings.TrimSpace(req.Name), strings.TrimSpace(req.Description))
|
||||
if err != nil {
|
||||
s.storeError(w, "save template", err)
|
||||
s.storeError(w, r, "save template", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTemplateJSON(*tpl))
|
||||
@@ -295,7 +295,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tpl, err := s.db.GetTemplate(r.Context(), user.ID, chi.URLParam(r, "templateID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "get template", err)
|
||||
s.storeError(w, r, "get template", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
project, err := s.db.CreateProjectFromTemplate(r.Context(), tpl, user.ID, strings.TrimSpace(req.Name), start)
|
||||
if err != nil {
|
||||
s.internal(w, "create from template", err)
|
||||
s.internal(w, r, "create from template", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, project.ID, "", "project.created", map[string]any{
|
||||
@@ -323,7 +323,7 @@ func (s *Server) handleUseTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteTemplate(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "templateID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete template", err)
|
||||
s.storeError(w, r, "delete template", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -27,7 +27,7 @@ func toFilterJSON(f store.Filter) filterJSON {
|
||||
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
|
||||
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list filters", err)
|
||||
s.internal(w, r, "list filters", err)
|
||||
return
|
||||
}
|
||||
out := make([]filterJSON, 0, len(filters))
|
||||
@@ -74,7 +74,7 @@ func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
f.SortOrder = *req.SortOrder
|
||||
}
|
||||
if err := s.db.CreateFilter(r.Context(), f); err != nil {
|
||||
s.internal(w, "create filter", err)
|
||||
s.internal(w, r, "create filter", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toFilterJSON(*f))
|
||||
@@ -108,7 +108,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
|
||||
})
|
||||
if err != nil {
|
||||
s.storeError(w, "update filter", err)
|
||||
s.storeError(w, r, "update filter", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -117,7 +117,7 @@ func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete filter", err)
|
||||
s.storeError(w, r, "delete filter", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -129,7 +129,7 @@ func (s *Server) handleRunFilter(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "get filter", err)
|
||||
s.storeError(w, r, "get filter", err)
|
||||
return
|
||||
}
|
||||
s.runQuery(w, r, f.Query)
|
||||
@@ -167,7 +167,7 @@ func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, query string)
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "run filter", err)
|
||||
s.internal(w, r, "run filter", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||
@@ -210,7 +210,7 @@ type labelJSON struct {
|
||||
func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
|
||||
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list labels", err)
|
||||
s.internal(w, r, "list labels", err)
|
||||
return
|
||||
}
|
||||
out := make([]labelJSON, 0, len(labels))
|
||||
@@ -242,7 +242,7 @@ func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create label", err)
|
||||
s.internal(w, r, "create label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||
@@ -261,7 +261,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||
return
|
||||
}
|
||||
s.storeError(w, "update label", err)
|
||||
s.storeError(w, r, "update label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
@@ -271,7 +271,7 @@ func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete label", err)
|
||||
s.storeError(w, r, "delete label", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "label.changed", nil)
|
||||
|
||||
@@ -10,12 +10,44 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) gmailConfig() gmail.Config {
|
||||
return gmail.Config{
|
||||
// gmailConfig is the OAuth client this instance authorises with.
|
||||
//
|
||||
// The environment wins when it is set. A value in the environment is a deliberate
|
||||
// deployment decision — it is in the Rune's manifest, under version control,
|
||||
// applied on every recreate — and a form that could quietly override it would make
|
||||
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
|
||||
// to redeploy a container to paste a client id.
|
||||
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||
cfg := gmail.Config{
|
||||
ClientID: s.cfg.GmailClientID,
|
||||
ClientSecret: s.cfg.GmailClientSecret,
|
||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||
}
|
||||
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||
return cfg
|
||||
}
|
||||
|
||||
stored, err := s.db.InstanceSettings(ctx, "gmail")
|
||||
if err != nil {
|
||||
// Not fatal: an unconfigured Gmail and a database that could not answer
|
||||
// look the same from here, and the caller's next step is the same either
|
||||
// way — say it is not set up.
|
||||
return cfg
|
||||
}
|
||||
if id, _ := stored["client_id"].(string); cfg.ClientID == "" {
|
||||
cfg.ClientID = id
|
||||
}
|
||||
if secret, _ := stored["client_secret"].(string); cfg.ClientSecret == "" {
|
||||
cfg.ClientSecret = secret
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// gmailFromEnv says whether the deployment set the client, which the settings page
|
||||
// needs so it can explain why the fields are not editable rather than accepting
|
||||
// changes that will never take effect.
|
||||
func (s *Server) gmailFromEnv() bool {
|
||||
return s.cfg.GmailClientID != "" && s.cfg.GmailClientSecret != ""
|
||||
}
|
||||
|
||||
// handleGmailAuthorize starts the flow: it mints a PKCE pair, remembers it against
|
||||
@@ -27,7 +59,7 @@ func (s *Server) gmailConfig() gmail.Config {
|
||||
// defaults.
|
||||
func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
cfg := s.gmailConfig()
|
||||
cfg := s.gmailConfig(r.Context())
|
||||
|
||||
if !cfg.Configured() {
|
||||
writeError(w, http.StatusConflict, CodeGmailNotConfigured,
|
||||
@@ -37,13 +69,13 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
pkce, err := gmail.NewPKCE()
|
||||
if err != nil {
|
||||
s.internal(w, "gmail pkce", err)
|
||||
s.internal(w, r, "gmail pkce", err)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
settings["pkce_verifier"] = pkce.Verifier
|
||||
@@ -53,7 +85,7 @@ func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
||||
@@ -83,7 +115,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
verifier, _ := settings["pkce_verifier"].(string)
|
||||
@@ -102,7 +134,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.gmailConfig().Exchange(r.Context(), code, gmail.PKCE{Verifier: verifier})
|
||||
token, err := s.gmailConfig(r.Context()).Exchange(r.Context(), code, gmail.PKCE{Verifier: verifier})
|
||||
if err != nil {
|
||||
s.log.Error("gmail exchange", "err", err, "user", user.ID)
|
||||
http.Redirect(w, r, settingsURL+"?gmail=failed", http.StatusFound)
|
||||
@@ -138,7 +170,7 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
||||
s.internal(w, "save gmail tokens", err)
|
||||
s.internal(w, r, "save gmail tokens", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
|
||||
@@ -185,7 +217,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
access := str("access_token")
|
||||
expires, _ := settings["expires_at"].(float64)
|
||||
if access == "" || int64(expires) <= time.Now().Unix() {
|
||||
token, err := s.gmailConfig().Refresh(ctx, refresh)
|
||||
token, err := s.gmailConfig(ctx).Refresh(ctx, refresh)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -300,3 +332,96 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
// --- the OAuth client itself ------------------------------------------------------
|
||||
|
||||
// The registration Google requires, enterable rather than deployed.
|
||||
//
|
||||
// The one click that opens Google's consent screen already exists; what it needs
|
||||
// behind it is a registered client, and that is not something any app can conjure:
|
||||
// Google issues no Gmail access to an unregistered client, and `gmail.readonly` is
|
||||
// a restricted scope, so an id shipped inside a public image would be both
|
||||
// extractable from it and unusable without Google's security review of every
|
||||
// deployment. The registration is a one-off. Having to edit a Rune's manifest and
|
||||
// recreate the container to paste the result was not.
|
||||
//
|
||||
// Administrators only, and sessions only: this is the instance's identity to
|
||||
// Google, and a leaked API token must not be able to read or replace it.
|
||||
|
||||
type gmailClientJSON struct {
|
||||
ClientID string `json:"client_id"`
|
||||
// Secret is written and never read back, like the AI key: a settings page that
|
||||
// repopulates a password field is one that will eventually leak into a
|
||||
// screenshot.
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
// FromEnv says the deployment set this, in which case the stored pair is
|
||||
// ignored and the fields are not worth editing. Sent so the page can say why
|
||||
// rather than accepting changes that will never take effect.
|
||||
FromEnv bool `json:"from_env"`
|
||||
// RedirectURI is what has to be registered with Google, spelled out. It is
|
||||
// derived from VERDANDE_BASE_URL and must match exactly — it is the single
|
||||
// most likely thing to be wrong, and the error Google gives for it names
|
||||
// neither value.
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetGmailClient(w http.ResponseWriter, r *http.Request) {
|
||||
stored, err := s.db.InstanceSettings(r.Context(), "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, r, "gmail client", err)
|
||||
return
|
||||
}
|
||||
out := gmailClientJSON{
|
||||
FromEnv: s.gmailFromEnv(),
|
||||
RedirectURI: s.cfg.GmailRedirectURL(),
|
||||
}
|
||||
if out.FromEnv {
|
||||
out.ClientID = s.cfg.GmailClientID
|
||||
out.HasSecret = true
|
||||
} else {
|
||||
out.ClientID, _ = stored["client_id"].(string)
|
||||
secret, _ := stored["client_secret"].(string)
|
||||
out.HasSecret = secret != ""
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
|
||||
var req gmailClientJSON
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if s.gmailFromEnv() {
|
||||
writeError(w, http.StatusConflict, CodeConflict,
|
||||
"the Gmail client is set in the environment and cannot be changed here")
|
||||
return
|
||||
}
|
||||
|
||||
stored, err := s.db.InstanceSettings(r.Context(), "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, r, "gmail client", err)
|
||||
return
|
||||
}
|
||||
// An empty secret means "leave it alone", not "delete it" — otherwise
|
||||
// correcting a typo in the client id would silently clear the secret.
|
||||
secret, _ := stored["client_secret"].(string)
|
||||
if req.ClientSecret != "" {
|
||||
secret = strings.TrimSpace(req.ClientSecret)
|
||||
}
|
||||
id := strings.TrimSpace(req.ClientID)
|
||||
|
||||
if fields := map[string]string{}; id == "" && secret != "" {
|
||||
fields["client_id"] = "required"
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.SetInstanceSettings(r.Context(), "gmail", map[string]any{
|
||||
"client_id": id, "client_secret": secret,
|
||||
}); err != nil {
|
||||
s.internal(w, r, "save gmail client", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func (s *Server) handleImportTodoist(w http.ResponseWriter, r *http.Request) {
|
||||
project := todoist.FromRows(rows, name)
|
||||
result, err := s.importProject(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "import project", err)
|
||||
s.internal(w, r, "import project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
@@ -240,7 +240,7 @@ func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := s.importProject(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "import csv", err)
|
||||
s.internal(w, r, "import csv", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
@@ -255,12 +255,12 @@ func (s *Server) handleExportProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
exported, err := s.buildExport(r, user, project)
|
||||
if err != nil {
|
||||
s.internal(w, "export project", err)
|
||||
s.internal(w, r, "export project", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
projects, err := s.db.ListProjects(ctx, user.ID, true)
|
||||
if err != nil {
|
||||
s.internal(w, "export projects", err)
|
||||
s.internal(w, r, "export projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func (s *Server) handleExportAccount(w http.ResponseWriter, r *http.Request) {
|
||||
ProjectIDs: []string{p.ID}, IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "export tasks", err)
|
||||
s.internal(w, r, "export tasks", err)
|
||||
return
|
||||
}
|
||||
for _, t := range tasks {
|
||||
@@ -445,14 +445,14 @@ func (s *Server) handleExportProjectICS(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
ProjectIDs: []string{projectID}, IncludeCompleted: true, Limit: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "export ics", err)
|
||||
s.internal(w, r, "export ics", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "mail token", err)
|
||||
s.internal(w, r, "mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
@@ -49,11 +49,11 @@ func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
|
||||
token, err := auth.NewToken()
|
||||
if err != nil {
|
||||
s.internal(w, "generate mail token", err)
|
||||
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, "set mail token", err)
|
||||
s.internal(w, r, "set mail token", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, mailAddressResponse{
|
||||
@@ -134,7 +134,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, "inbox", err)
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
|
||||
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
|
||||
s.internal(w, "create task from mail", err)
|
||||
s.internal(w, r, "create task from mail", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ type aiSettings struct {
|
||||
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, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
// The key is never sent back. A settings page that repopulates a password
|
||||
@@ -216,7 +216,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
existing, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
// An empty key means "leave it alone", not "delete it" — otherwise saving a
|
||||
@@ -231,7 +231,7 @@ func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||
"model": req.Model, "api_key": key,
|
||||
}
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
|
||||
s.internal(w, "save ai settings", err)
|
||||
s.internal(w, r, "save ai settings", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -266,7 +266,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cfg, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
@@ -278,7 +278,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
|
||||
Priority: task.Priority, CreatedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
|
||||
s.internal(w, "create subtask", err)
|
||||
s.internal(w, r, "create subtask", err)
|
||||
return
|
||||
}
|
||||
created = append(created, toTaskJSON(*child))
|
||||
@@ -315,7 +315,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
cfg, err := s.aiConfig(r, user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "ai settings", err)
|
||||
s.internal(w, r, "ai settings", err)
|
||||
return
|
||||
}
|
||||
if !cfg.Configured() {
|
||||
@@ -326,7 +326,7 @@ func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
|
||||
if err != nil {
|
||||
s.internal(w, "list tasks", err)
|
||||
s.internal(w, r, "list tasks", err)
|
||||
return
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
@@ -372,7 +372,7 @@ type gmailSettings struct {
|
||||
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
||||
values, err := s.db.UserSettings(r.Context(), userFrom(r.Context()).ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
str := func(key string) string {
|
||||
@@ -396,14 +396,14 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
values, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
||||
if err != nil {
|
||||
s.internal(w, "gmail settings", err)
|
||||
s.internal(w, r, "gmail settings", err)
|
||||
return
|
||||
}
|
||||
values["trigger"] = req.Trigger
|
||||
values["label"] = req.Label
|
||||
|
||||
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", values); err != nil {
|
||||
s.internal(w, "save gmail settings", err)
|
||||
s.internal(w, r, "save gmail settings", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -412,7 +412,7 @@ func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
|
||||
// handleDisconnectGmail forgets the connection, tokens and all.
|
||||
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.SetUserSettings(r.Context(), userFrom(r.Context()).ID, "gmail", map[string]any{}); err != nil {
|
||||
s.internal(w, "disconnect gmail", err)
|
||||
s.internal(w, r, "disconnect gmail", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -1153,3 +1153,130 @@ func TestMCPCreateRefusesASecondLevelOfSubtask(t *testing.T) {
|
||||
t.Errorf("a sub-task of a sub-task was accepted: %v", response)
|
||||
}
|
||||
}
|
||||
|
||||
// The Gmail OAuth client, enterable rather than deployed.
|
||||
//
|
||||
// The registration itself cannot be avoided — Google issues no Gmail access to an
|
||||
// unregistered client — but having to edit a Rune's manifest and recreate the
|
||||
// container to paste the result can be, and that is what this covers.
|
||||
func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, empty := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||
if empty["client_id"] != "" || empty["has_secret"] != false {
|
||||
t.Errorf("a fresh instance already has a client: %v", empty)
|
||||
}
|
||||
if empty["from_env"] != false {
|
||||
t.Errorf("from_env = %v with nothing in the environment", empty["from_env"])
|
||||
}
|
||||
// The single most likely thing to be wrong, so it is spelled out rather than
|
||||
// left for somebody to reconstruct.
|
||||
if empty["redirect_uri"] != "http://127.0.0.1/oauth/gmail/callback" {
|
||||
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
|
||||
}
|
||||
|
||||
resp, _ := ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
|
||||
"client_id": "1234.apps.googleusercontent.com", "client_secret": "hemmelig",
|
||||
})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("save: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, saved := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||
if saved["client_id"] != "1234.apps.googleusercontent.com" {
|
||||
t.Errorf("client_id = %v", saved["client_id"])
|
||||
}
|
||||
if saved["has_secret"] != true {
|
||||
t.Error("has_secret is false after saving one")
|
||||
}
|
||||
// Never read back. A settings page that repopulates a password field is one
|
||||
// that will eventually leak into a screenshot.
|
||||
if _, present := saved["client_secret"]; present {
|
||||
t.Error("the secret came back in the response")
|
||||
}
|
||||
|
||||
// Saving only the id must not clear the secret — otherwise correcting a typo
|
||||
// silently breaks the connection.
|
||||
ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
|
||||
"client_id": "5678.apps.googleusercontent.com",
|
||||
})
|
||||
_, again := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||
if again["client_id"] != "5678.apps.googleusercontent.com" || again["has_secret"] != true {
|
||||
t.Errorf("saving the id alone lost the secret: %v", again)
|
||||
}
|
||||
|
||||
// And the authorise call now has something to build a URL with, which is the
|
||||
// whole point: one click, and Google's consent screen.
|
||||
resp, body := ts.do(t, "POST", "/api/v1/gmail/authorize", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("authorize with a stored client: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if url, _ := body["url"].(string); !strings.Contains(url, "5678.apps.googleusercontent.com") {
|
||||
t.Errorf("the consent URL does not carry the stored client: %v", body["url"])
|
||||
}
|
||||
}
|
||||
|
||||
// The environment wins, and says so. A value in the Rune's manifest is applied on
|
||||
// every recreate, so a form that could quietly override it would make the manifest
|
||||
// a lie.
|
||||
func TestTheEnvironmentsGmailClientCannotBeOverwritten(t *testing.T) {
|
||||
ts := newTestServerWith(t, func(c *config.Config) {
|
||||
c.GmailClientID = "fra-miljøet.apps.googleusercontent.com"
|
||||
c.GmailClientSecret = "også-fra-miljøet"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, shown := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||
if shown["from_env"] != true {
|
||||
t.Errorf("from_env = %v with the environment set", shown["from_env"])
|
||||
}
|
||||
if shown["client_id"] != "fra-miljøet.apps.googleusercontent.com" {
|
||||
t.Errorf("client_id = %v", shown["client_id"])
|
||||
}
|
||||
|
||||
resp, _ := ts.do(t, "PUT", "/api/v1/gmail/client", map[string]any{
|
||||
"client_id": "min-egen", "client_secret": "min-egen",
|
||||
})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("overwriting the environment: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, after := ts.do(t, "GET", "/api/v1/gmail/client", nil)
|
||||
if after["client_id"] != "fra-miljøet.apps.googleusercontent.com" {
|
||||
t.Errorf("the environment's client was replaced: %v", after["client_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// Administrators only, and sessions only: this is the instance's identity to
|
||||
// Google, and a leaked token must not be able to read or replace it.
|
||||
func TestTheGmailClientIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
for _, c := range []struct {
|
||||
method string
|
||||
body any
|
||||
}{{"GET", nil}, {"PUT", map[string]any{"client_id": "x"}}} {
|
||||
if resp, _ := other.do(t, c.method, "/api/v1/gmail/client", c.body); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s as an ordinary user: status %d, want 403", c.method, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/gmail/client", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("with an admin's API token: status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.db.UserByAPIToken(r.Context(), key)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
s.internal(w, "mcp key", err)
|
||||
s.internal(w, r, "mcp key", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
|
||||
|
||||
@@ -83,7 +83,7 @@ func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
// as everybody being logged out at once — the frontend clears its state
|
||||
// and shows the sign-in screen — and nothing in the logs says why.
|
||||
if !errors.Is(err, errNoCredentials) && !errors.Is(err, store.ErrNotFound) {
|
||||
s.internal(w, "authenticate", err)
|
||||
s.internal(w, r, "authenticate", err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in")
|
||||
|
||||
@@ -27,12 +27,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
list, err := s.db.ListNotifications(r.Context(), user.ID, 30)
|
||||
if err != nil {
|
||||
s.internal(w, "list notifications", err)
|
||||
s.internal(w, r, "list notifications", err)
|
||||
return
|
||||
}
|
||||
unread, err := s.db.UnreadNotificationCount(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "count notifications", err)
|
||||
s.internal(w, r, "count notifications", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
func (s *Server) handleMarkNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.MarkNotificationsRead(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "notificationID"))
|
||||
if err != nil {
|
||||
s.internal(w, "mark notifications read", err)
|
||||
s.internal(w, r, "mark notifications read", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -148,7 +148,7 @@ type pushSubscriptionRequest struct {
|
||||
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
|
||||
public, _, err := s.vapidKeys(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "vapid keys", err)
|
||||
s.internal(w, r, "vapid keys", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"public_key": public})
|
||||
@@ -172,7 +172,7 @@ func (s *Server) handleSubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
UserAgent: r.UserAgent(),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "save push subscription", err)
|
||||
s.internal(w, r, "save push subscription", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -184,7 +184,7 @@ func (s *Server) handleUnsubscribePush(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeletePushSubscription(r.Context(), req.Endpoint); err != nil {
|
||||
s.internal(w, "delete push subscription", err)
|
||||
s.internal(w, r, "delete push subscription", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -2,6 +2,8 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -17,16 +19,17 @@ import (
|
||||
// that is not yours is not found.
|
||||
|
||||
type projectGroupJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
}
|
||||
|
||||
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
||||
return projectGroupJSON{
|
||||
ID: g.ID, Name: g.Name, Color: g.Color,
|
||||
ID: g.ID, Name: g.Name, Color: g.Color, Description: g.Description,
|
||||
Collapsed: g.Collapsed, SortOrder: g.SortOrder,
|
||||
}
|
||||
}
|
||||
@@ -34,7 +37,7 @@ func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
||||
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list project groups", err)
|
||||
s.internal(w, r, "list project groups", err)
|
||||
return
|
||||
}
|
||||
out := make([]projectGroupJSON, 0, len(groups))
|
||||
@@ -45,9 +48,10 @@ func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
type projectGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
Description *string `json:"description"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -73,11 +77,14 @@ func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request
|
||||
if req.Color != nil {
|
||||
g.Color = *req.Color
|
||||
}
|
||||
if req.Description != nil {
|
||||
g.Description = strings.TrimSpace(*req.Description)
|
||||
}
|
||||
if req.Collapsed != nil {
|
||||
g.Collapsed = *req.Collapsed
|
||||
}
|
||||
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
|
||||
s.internal(w, "create project group", err)
|
||||
s.internal(w, r, "create project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
|
||||
@@ -105,28 +112,158 @@ func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
if req.Description != nil {
|
||||
description := strings.TrimSpace(*req.Description)
|
||||
if len(description) > 4000 {
|
||||
writeFieldErrors(w, map[string]string{"description": "must be at most 4000 characters"})
|
||||
return
|
||||
}
|
||||
req.Description = &description
|
||||
}
|
||||
|
||||
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
|
||||
Name: req.Name, Color: req.Color, Collapsed: req.Collapsed,
|
||||
Name: req.Name, Color: req.Color, Description: req.Description,
|
||||
Collapsed: req.Collapsed,
|
||||
}); err != nil {
|
||||
s.storeError(w, "update project group", err)
|
||||
s.storeError(w, r, "update project group", err)
|
||||
return
|
||||
}
|
||||
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project group", err)
|
||||
s.storeError(w, r, "get project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
|
||||
writeJSON(w, http.StatusOK, toProjectGroupJSON(*g))
|
||||
}
|
||||
|
||||
// groupProjectJSON is a project as the group's page lists it: the project, plus
|
||||
// how much is left in it. Open tasks, not all of them — "12" beside a project
|
||||
// somebody finished last year is a number that means nothing.
|
||||
type groupProjectJSON struct {
|
||||
projectJSON
|
||||
OpenTasks int `json:"open_tasks"`
|
||||
}
|
||||
|
||||
// handleGetProjectGroup is the group as a place rather than a heading.
|
||||
//
|
||||
// A heading can carry a name. A page carries what the group *is* — the sentence
|
||||
// that says why these projects are one body of work, the documents that belong to
|
||||
// all of them rather than to any one, and the projects themselves with what is
|
||||
// left in each.
|
||||
//
|
||||
// The projects come with the group rather than being fetched per row, because the
|
||||
// page is nothing without them and two round trips to draw one list is one too
|
||||
// many.
|
||||
func (s *Server) handleGetProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
userID := userFrom(r.Context()).ID
|
||||
|
||||
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.storeError(w, r, "get project group", err)
|
||||
return
|
||||
}
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), userID, false)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list projects", err)
|
||||
return
|
||||
}
|
||||
counts, err := s.db.OpenTaskCounts(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "open task counts", err)
|
||||
return
|
||||
}
|
||||
inside := make([]groupProjectJSON, 0, 4)
|
||||
for _, p := range projects {
|
||||
if p.GroupID == groupID {
|
||||
inside = append(inside, groupProjectJSON{
|
||||
projectJSON: toProjectJSON(p), OpenTasks: counts[p.ID],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
files, err := s.db.ListGroupAttachments(r.Context(), groupID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list group attachments", err)
|
||||
return
|
||||
}
|
||||
attachments := make([]attachmentJSON, 0, len(files))
|
||||
for _, a := range files {
|
||||
attachments = append(attachments, toAttachmentJSON(a))
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"group": toProjectGroupJSON(*g), "projects": inside, "attachments": attachments,
|
||||
})
|
||||
}
|
||||
|
||||
// handleUploadGroupAttachment puts a document on the group itself.
|
||||
//
|
||||
// The same storage as everywhere else — content-addressed by hash, so the path
|
||||
// never contains anything the uploader chose. What differs is only the parent.
|
||||
func (s *Server) handleUploadGroupAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
user := userFrom(r.Context())
|
||||
|
||||
// Scoped by the lookup rather than by a separate check: a group that is not
|
||||
// yours is not found, which is the rule for everything in this file.
|
||||
if _, err := s.db.GetProjectGroup(r.Context(), groupID, user.ID); err != nil {
|
||||
s.storeError(w, r, "get project group", err)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+(1<<20))
|
||||
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeFieldErrors(w, map[string]string{"file": "required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > maxUploadBytes {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge,
|
||||
"that file is too large (25 MB maximum)")
|
||||
return
|
||||
}
|
||||
|
||||
stored, size, err := s.storeUpload(file)
|
||||
if err != nil {
|
||||
s.internal(w, r, "store upload", err)
|
||||
return
|
||||
}
|
||||
|
||||
a := &store.Attachment{
|
||||
GroupID: groupID,
|
||||
Filename: safeFilename(header.Filename),
|
||||
MimeType: detectMime(header.Filename, header.Header.Get("Content-Type")),
|
||||
Size: size,
|
||||
Path: stored,
|
||||
UploadedBy: user.ID,
|
||||
}
|
||||
if err := s.db.CreateAttachment(r.Context(), a); err != nil {
|
||||
os.Remove(filepath.Join(s.cfg.FilesDir(), stored))
|
||||
s.internal(w, r, "record attachment", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toAttachmentJSON(*a))
|
||||
}
|
||||
|
||||
// handleDeleteProjectGroup removes the heading. The projects under it come back
|
||||
// out as ungrouped, which is why this is not behind a confirmation about losing
|
||||
// work: there is none to lose.
|
||||
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, "delete project group", err)
|
||||
s.storeError(w, r, "delete project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
|
||||
@@ -145,7 +282,7 @@ func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder project groups", err)
|
||||
s.internal(w, r, "reorder project groups", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -269,3 +269,83 @@ func TestAColourMustBeOneFromThePalette(t *testing.T) {
|
||||
t.Errorf("name = %v", renamed["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// A group is somewhere you can go, not only a heading.
|
||||
func TestAGroupIsAPageWithItsProjectsDescriptionAndFiles(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{
|
||||
"name": "Sæsonstart", "group_id": groupID,
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab", "group_id": groupID})
|
||||
// And one outside it, which must not appear.
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Haven"})
|
||||
|
||||
// Two open, one closed — the count is what is left, not what has ever been.
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "skriv oplæg", "project_id": first["id"].(string),
|
||||
})
|
||||
_, done := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "book lokale", "project_id": first["id"].(string),
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks/"+done["id"].(string)+"/complete", nil)
|
||||
|
||||
resp, page := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get group: status %d, body %v", resp.StatusCode, page)
|
||||
}
|
||||
projects, _ := page["projects"].([]any)
|
||||
if len(projects) != 2 {
|
||||
t.Fatalf("the page lists %d projects, want 2", len(projects))
|
||||
}
|
||||
for _, raw := range projects {
|
||||
p := raw.(map[string]any)
|
||||
if p["name"] == "Haven" {
|
||||
t.Error("a project outside the group is on its page")
|
||||
}
|
||||
if p["name"] == "Sæsonstart" && p["open_tasks"] != float64(1) {
|
||||
t.Errorf("open_tasks = %v, want 1 — the closed one must not count", p["open_tasks"])
|
||||
}
|
||||
}
|
||||
|
||||
// The description, which is the part a heading cannot carry.
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID, map[string]any{
|
||||
"description": "Alt det, der har en faktura for enden.",
|
||||
}); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("set description: status %d", resp.StatusCode)
|
||||
}
|
||||
_, again := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
|
||||
if g, _ := again["group"].(map[string]any); g["description"] != "Alt det, der har en faktura for enden." {
|
||||
t.Errorf("description = %v", g["description"])
|
||||
}
|
||||
// And setting only the description must not lose the name or colour.
|
||||
if g, _ := again["group"].(map[string]any); g["name"] != "Arbejde" {
|
||||
t.Errorf("the name went with the description: %v", g["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// A group belongs to one person and is never shared, so somebody else's group is
|
||||
// not found — including its files.
|
||||
func TestAGroupsPageAndFilesAreItsOwnersAlone(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/project-groups/" + groupID},
|
||||
{"PATCH", "/api/v1/project-groups/" + groupID},
|
||||
{"POST", "/api/v1/project-groups/" + groupID + "/attachments"},
|
||||
} {
|
||||
resp, _ := other.do(t, c.method, c.path, map[string]any{"description": "min nu"})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s %s as somebody else: status %d, want 404", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, includeArchived)
|
||||
if err != nil {
|
||||
s.internal(w, "list projects", err)
|
||||
s.internal(w, r, "list projects", err)
|
||||
return
|
||||
}
|
||||
out := make([]projectJSON, 0, len(projects))
|
||||
@@ -59,7 +59,7 @@ func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
p, err := s.db.GetProject(r.Context(), chi.URLParam(r, "projectID"), user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toProjectJSON(*p))
|
||||
@@ -105,7 +105,7 @@ func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder projects", err)
|
||||
s.internal(w, r, "reorder projects", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -138,9 +138,21 @@ func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
p.ViewMode = *req.ViewMode
|
||||
}
|
||||
if err := s.db.CreateProject(r.Context(), p); err != nil {
|
||||
s.internal(w, "create project", err)
|
||||
s.internal(w, r, "create project", err)
|
||||
return
|
||||
}
|
||||
// Filed under a group in the same call. `group_id` was declared on this
|
||||
// request type, documented on it, applied by the update handler — and silently
|
||||
// dropped here, so creating a project into a group answered 201 and put it
|
||||
// nowhere. A field that is accepted and thrown away is worse than one that is
|
||||
// refused.
|
||||
if req.GroupID != nil && *req.GroupID != "" {
|
||||
if err := s.db.SetProjectGroup(r.Context(), p.ID, *req.GroupID, user.ID); err != nil {
|
||||
s.storeError(w, r, "set project group", err)
|
||||
return
|
||||
}
|
||||
p.GroupID = *req.GroupID
|
||||
}
|
||||
s.activity(r, p.ID, "", "project.created", map[string]any{"name": p.Name})
|
||||
s.publishToOwner(r, "project.created", toProjectJSON(*p))
|
||||
writeJSON(w, http.StatusCreated, toProjectJSON(*p))
|
||||
@@ -174,7 +186,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
ViewMode: req.ViewMode, Archived: req.Archived,
|
||||
})
|
||||
if err != nil {
|
||||
s.storeError(w, "update project", err)
|
||||
s.storeError(w, r, "update project", err)
|
||||
return
|
||||
}
|
||||
// Grouping is written separately because it is scoped differently: the fields
|
||||
@@ -184,7 +196,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
if req.GroupID != nil {
|
||||
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
|
||||
userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, "set project group", err)
|
||||
s.storeError(w, r, "set project group", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -192,7 +204,7 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project.updated", toProjectJSON(*p))
|
||||
@@ -206,7 +218,7 @@ func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusConflict, CodeInboxProtected, "the Inbox cannot be deleted")
|
||||
return
|
||||
}
|
||||
s.storeError(w, "delete project", err)
|
||||
s.storeError(w, r, "delete project", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "project.deleted", nil)
|
||||
@@ -230,7 +242,7 @@ type trashedProjectJSON struct {
|
||||
func (s *Server) handleListTrashedProjects(w http.ResponseWriter, r *http.Request) {
|
||||
trashed, err := s.db.ListTrashedProjects(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list trashed projects", err)
|
||||
s.internal(w, r, "list trashed projects", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -252,7 +264,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||
// deliberately invisible to it. Ownership is what governs restoring.
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, "project owner", err)
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
if owner != userFrom(r.Context()).ID {
|
||||
@@ -260,7 +272,7 @@ func (s *Server) handleRestoreProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.RestoreProject(r.Context(), projectID); err != nil {
|
||||
s.storeError(w, "restore project", err)
|
||||
s.storeError(w, r, "restore project", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -306,7 +318,7 @@ type sectionJSON struct {
|
||||
func (s *Server) handleListSections(w http.ResponseWriter, r *http.Request) {
|
||||
sections, err := s.db.ListSections(r.Context(), chi.URLParam(r, "projectID"))
|
||||
if err != nil {
|
||||
s.internal(w, "list sections", err)
|
||||
s.internal(w, r, "list sections", err)
|
||||
return
|
||||
}
|
||||
out := make([]sectionJSON, 0, len(sections))
|
||||
@@ -336,7 +348,7 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
|
||||
sec.SortOrder = *req.SortOrder
|
||||
}
|
||||
if err := s.db.CreateSection(r.Context(), sec); err != nil {
|
||||
s.internal(w, "create section", err)
|
||||
s.internal(w, r, "create section", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, sec.ProjectID, "", "section.created", map[string]any{"name": sec.Name})
|
||||
@@ -350,7 +362,7 @@ func (s *Server) sectionAccess(w http.ResponseWriter, r *http.Request, min store
|
||||
sectionID := chi.URLParam(r, "sectionID")
|
||||
projectID, err := s.db.SectionProject(r.Context(), sectionID)
|
||||
if err != nil {
|
||||
s.storeError(w, "section project", err)
|
||||
s.storeError(w, r, "section project", err)
|
||||
return "", "", false
|
||||
}
|
||||
if _, err := store.RequireProjectRole(r.Context(), s.db, projectID, userFrom(r.Context()).ID, min); err != nil {
|
||||
@@ -381,7 +393,7 @@ func (s *Server) handleUpdateSection(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.UpdateSection(r.Context(), sectionID, store.SectionUpdate{
|
||||
Name: req.Name, SortOrder: req.SortOrder,
|
||||
}); err != nil {
|
||||
s.storeError(w, "update section", err)
|
||||
s.storeError(w, r, "update section", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "section.updated", nil)
|
||||
@@ -394,7 +406,7 @@ func (s *Server) handleDeleteSection(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteSection(r.Context(), sectionID); err != nil {
|
||||
s.storeError(w, "delete section", err)
|
||||
s.storeError(w, r, "delete section", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "section.deleted", nil)
|
||||
@@ -414,7 +426,7 @@ type memberJSON struct {
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
members, err := s.db.ListMembers(r.Context(), chi.URLParam(r, "projectID"))
|
||||
if err != nil {
|
||||
s.internal(w, "list members", err)
|
||||
s.internal(w, r, "list members", err)
|
||||
return
|
||||
}
|
||||
out := make([]memberJSON, 0, len(members))
|
||||
@@ -463,7 +475,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
project, err := s.db.GetProject(r.Context(), projectID, inviter.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project", err)
|
||||
s.storeError(w, r, "get project", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -475,7 +487,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.AddMember(r.Context(), projectID, existing.ID, role); err != nil {
|
||||
s.internal(w, "add member", err)
|
||||
s.internal(w, r, "add member", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.added", map[string]any{"email": email, "role": string(role)})
|
||||
@@ -485,7 +497,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token, _, err := s.db.CreateInvite(r.Context(), email, projectID, role, inviter.ID, s.cfg.InviteTTL)
|
||||
if err != nil {
|
||||
s.internal(w, "create invite", err)
|
||||
s.internal(w, r, "create invite", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||
@@ -531,7 +543,7 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
// unhelpful. Say the real reason instead.
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, "project owner", err)
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
if userID == owner {
|
||||
@@ -541,7 +553,7 @@ func (s *Server) handleSetMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.SetMemberRole(r.Context(), projectID, userID, role); err != nil {
|
||||
s.storeError(w, "set member role", err)
|
||||
s.storeError(w, r, "set member role", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.role_changed",
|
||||
@@ -555,7 +567,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
owner, err := s.db.ProjectOwner(r.Context(), projectID)
|
||||
if err != nil {
|
||||
s.storeError(w, "project owner", err)
|
||||
s.storeError(w, r, "project owner", err)
|
||||
return
|
||||
}
|
||||
if userID == owner {
|
||||
@@ -564,7 +576,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.RemoveMember(r.Context(), projectID, userID); err != nil {
|
||||
s.storeError(w, "remove member", err)
|
||||
s.storeError(w, r, "remove member", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, projectID, "", "member.removed", map[string]any{"user_id": userID})
|
||||
@@ -576,13 +588,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
// Everything a caller is not allowed to see is reported as 404 rather than 403: a
|
||||
// 403 confirms the thing exists, which is exactly what somebody probing ids wants
|
||||
// to learn.
|
||||
func (s *Server) storeError(w http.ResponseWriter, what string, err error) {
|
||||
func (s *Server) storeError(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrNoAccess):
|
||||
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
|
||||
case errors.Is(err, store.ErrEmailInUse):
|
||||
writeError(w, http.StatusConflict, CodeConflict, "that email address already has an account")
|
||||
default:
|
||||
s.internal(w, what, err)
|
||||
s.internal(w, r, what, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,3 +283,141 @@ func TestTheOwnersStandingCannotBeChanged(t *testing.T) {
|
||||
t.Errorf("a non-member was given a role: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A project can have more than one section.
|
||||
//
|
||||
// Reported from use: sections "have no function, because you cannot create more
|
||||
// than one". Every test here made exactly one, which is the shape of test that
|
||||
// cannot see this — and so is every smoke test.
|
||||
func TestAProjectCanHaveSeveralSections(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
|
||||
id := project["id"].(string)
|
||||
|
||||
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
|
||||
for _, name := range names {
|
||||
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections",
|
||||
map[string]any{"name": name})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create section %q: status %d, body %v", name, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
|
||||
got, _ := listed["sections"].([]any)
|
||||
if len(got) != len(names) {
|
||||
t.Fatalf("the project has %d sections, want %d", len(got), len(names))
|
||||
}
|
||||
// In the order they were made, and each with its own place: a sort_order that
|
||||
// came out the same for all three would put them in an arbitrary order that
|
||||
// changes between reads.
|
||||
seen := map[float64]bool{}
|
||||
for i, raw := range got {
|
||||
s := raw.(map[string]any)
|
||||
if s["name"] != names[i] {
|
||||
t.Errorf("section %d is %v, want %q", i, s["name"], names[i])
|
||||
}
|
||||
order, _ := s["sort_order"].(float64)
|
||||
if seen[order] {
|
||||
t.Errorf("two sections share sort_order %v", order)
|
||||
}
|
||||
seen[order] = true
|
||||
}
|
||||
}
|
||||
|
||||
// A project can be created straight into a group.
|
||||
//
|
||||
// `group_id` was declared on the request type, documented on it, and applied by
|
||||
// the update handler — but the create handler ignored it, so a client that asked
|
||||
// for a project in a group got a 201 and a project in no group. A field that is
|
||||
// accepted and thrown away is worse than one that is refused: nothing reports it.
|
||||
func TestAProjectCanBeCreatedIntoAGroup(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
resp, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{
|
||||
"name": "Sæsonstart", "group_id": groupID,
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: status %d, body %v", resp.StatusCode, project)
|
||||
}
|
||||
if project["group_id"] != groupID {
|
||||
t.Errorf("the response says group_id = %v, want %q", project["group_id"], groupID)
|
||||
}
|
||||
|
||||
// And it is really filed there, not just claimed in the reply.
|
||||
_, listed := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
var found map[string]any
|
||||
for _, raw := range listed["projects"].([]any) {
|
||||
if p := raw.(map[string]any); p["id"] == project["id"] {
|
||||
found = p
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("the new project is not in the list")
|
||||
}
|
||||
if found["group_id"] != groupID {
|
||||
t.Errorf("the stored project has group_id = %v, want %q", found["group_id"], groupID)
|
||||
}
|
||||
}
|
||||
|
||||
// A section holds as many tasks as you put in it.
|
||||
//
|
||||
// Reported from use: "sections can only have one task". Driven through the same
|
||||
// endpoint the interface uses, with the same `after_id` the drop handler sends —
|
||||
// the second task goes after the first, which is the case that would fail if
|
||||
// positioning inside a section were wrong.
|
||||
func TestASectionHoldsMoreThanOneTask(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Skovvænget"})
|
||||
projectID := project["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||
map[string]any{"name": "Håndværker"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
var previous string
|
||||
for _, content := range []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"} {
|
||||
_, created := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": content, "project_id": projectID,
|
||||
})
|
||||
id := created["id"].(string)
|
||||
|
||||
resp, moved := ts.do(t, "POST", "/api/v1/tasks/"+id+"/move", map[string]any{
|
||||
"project_id": projectID, "section_id": sectionID, "after_id": previous,
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("move %q: status %d, body %v", content, resp.StatusCode, moved)
|
||||
}
|
||||
if moved["section_id"] != sectionID {
|
||||
t.Fatalf("%q did not land in the section: %v", content, moved["section_id"])
|
||||
}
|
||||
previous = id
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
|
||||
tasks, _ := listed["tasks"].([]any)
|
||||
inSection := []string{}
|
||||
for _, raw := range tasks {
|
||||
task := raw.(map[string]any)
|
||||
if task["section_id"] == sectionID {
|
||||
inSection = append(inSection, task["content"].(string))
|
||||
}
|
||||
}
|
||||
if len(inSection) != 3 {
|
||||
t.Fatalf("the section holds %d tasks, want 3: %v", len(inSection), inSection)
|
||||
}
|
||||
// And in the order they were placed, since each went after the one before it.
|
||||
want := []string{"knager på badeværelset", "plade på opvaskemaskine", "fuge om vinduet"}
|
||||
for i, content := range want {
|
||||
if inSection[i] != content {
|
||||
t.Errorf("position %d is %q, want %q — the whole list: %v", i, inSection[i], content, inSection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(s.requireAuth)
|
||||
r.Get("/me", s.handleMe)
|
||||
r.Patch("/me", s.handleUpdateProfile)
|
||||
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
@@ -236,6 +237,17 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/", s.handleDisconnectGmail)
|
||||
r.Post("/authorize", s.handleGmailAuthorize)
|
||||
r.Post("/sync", s.handleGmailSyncNow)
|
||||
|
||||
// The OAuth client itself is the instance's registration with
|
||||
// Google, not anybody's mailbox — so unlike everything above it, it
|
||||
// is administrators only, and behind a session as well: a leaked
|
||||
// token must not be able to read or replace this instance's
|
||||
// identity to Google.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireSession, s.requireAdmin)
|
||||
r.Get("/client", s.handleGetGmailClient)
|
||||
r.Put("/client", s.handleSetGmailClient)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
@@ -305,6 +317,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// delete somebody else's.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireSession, s.requireAdmin)
|
||||
r.Get("/errors", s.handleListErrors)
|
||||
// A backup file is a complete copy of the database, so this whole
|
||||
// group is sessions-only as well as administrators-only: a leaked
|
||||
// token must not be able to download everybody's data.
|
||||
r.Get("/backups", s.handleListBackups)
|
||||
r.Post("/backups", s.handleRunBackup)
|
||||
r.Get("/backups/{backupID}", s.handleDownloadBackup)
|
||||
// The instance-wide history. Above the per-project one in every
|
||||
// sense: it crosses projects the caller is not a member of, which
|
||||
// is the whole reason it is here and not there.
|
||||
r.Get("/activity", s.handleAuditLog)
|
||||
r.Get("/activity/events", s.handleAuditEvents)
|
||||
r.Get("/users", s.handleListUsers)
|
||||
r.Post("/users", s.handleCreateUser)
|
||||
r.Patch("/users/{userID}", s.handleUpdateUser)
|
||||
@@ -320,8 +344,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Post("/", s.handleCreateProjectGroup)
|
||||
// Above /{groupID}, or chi reads "reorder" as an id.
|
||||
r.Post("/reorder", s.handleReorderProjectGroups)
|
||||
r.Get("/{groupID}", s.handleGetProjectGroup)
|
||||
r.Patch("/{groupID}", s.handleUpdateProjectGroup)
|
||||
r.Delete("/{groupID}", s.handleDeleteProjectGroup)
|
||||
r.Post("/{groupID}/attachments", s.handleUploadGroupAttachment)
|
||||
})
|
||||
|
||||
// The trash is outside the block above: a deleted project is invisible
|
||||
|
||||
@@ -41,7 +41,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sessions, err := s.db.ListSessions(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list sessions", err)
|
||||
s.internal(w, r, "list sessions", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := chi.URLParam(r, "sessionID")
|
||||
|
||||
if err := s.db.DeleteUserSession(r.Context(), sessionID, user.ID); err != nil {
|
||||
s.storeError(w, "delete session", err)
|
||||
s.storeError(w, r, "delete session", err)
|
||||
return
|
||||
}
|
||||
if current := sessionFrom(r.Context()); current != nil && current.ID == sessionID {
|
||||
|
||||
@@ -33,9 +33,14 @@ type taskJSON struct {
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// What the row can say without being opened. Sent with every task rather than
|
||||
// fetched when one is opened, because the point is that you see it in the list.
|
||||
SubtaskCount int `json:"subtask_count,omitempty"`
|
||||
SubtaskDone int `json:"subtask_done,omitempty"`
|
||||
AttachmentCount int `json:"attachment_count,omitempty"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toTaskJSON(t store.Task) taskJSON {
|
||||
@@ -45,9 +50,11 @@ func toTaskJSON(t store.Task) taskJSON {
|
||||
DueDate: t.DueDate, DurationMin: t.DurationMin, Recurrence: t.RecurrenceRule,
|
||||
RepeatText: recurrence.Describe(t.RecurrenceRule),
|
||||
AssigneeID: t.AssigneeID, Completed: t.Completed(), CreatedBy: t.CreatedBy,
|
||||
SortOrder: t.SortOrder,
|
||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||
SubtaskCount: t.SubtaskCount, SubtaskDone: t.SubtaskDone,
|
||||
AttachmentCount: t.AttachmentCount,
|
||||
SortOrder: t.SortOrder,
|
||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||
// Never nil: a client iterating labels should not have to null-check first.
|
||||
Labels: t.Labels,
|
||||
}
|
||||
@@ -99,7 +106,7 @@ func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, f)
|
||||
if err != nil {
|
||||
s.internal(w, "list tasks", err)
|
||||
s.internal(w, r, "list tasks", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||
@@ -116,7 +123,7 @@ func toTaskList(tasks []store.Task) []taskJSON {
|
||||
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := s.db.GetTask(r.Context(), chi.URLParam(r, "taskID"), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toTaskJSON(*t))
|
||||
@@ -162,7 +169,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
if projectID == "" {
|
||||
var err error
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, "inbox", err)
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -226,7 +233,7 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.CreateTask(r.Context(), t, req.Labels); err != nil {
|
||||
s.internal(w, "create task", err)
|
||||
s.internal(w, r, "create task", err)
|
||||
return
|
||||
}
|
||||
// The store wrote the labels; the struct it was handed does not know that, and
|
||||
@@ -302,13 +309,13 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.UpdateTask(r.Context(), taskID, user.ID, u); err != nil {
|
||||
s.storeError(w, "update task", err)
|
||||
s.storeError(w, r, "update task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
|
||||
@@ -327,13 +334,13 @@ func (s *Server) handleCompleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
result, err := s.db.CompleteTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "complete task", err)
|
||||
s.storeError(w, r, "complete task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -369,13 +376,13 @@ func (s *Server) handleReopenTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.ReopenTask(r.Context(), taskID); err != nil {
|
||||
s.storeError(w, "reopen task", err)
|
||||
s.storeError(w, r, "reopen task", err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, t.ID, "task.reopened", nil)
|
||||
@@ -394,11 +401,11 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.DeleteTask(r.Context(), taskID); err != nil {
|
||||
s.storeError(w, "delete task", err)
|
||||
s.storeError(w, r, "delete task", err)
|
||||
return
|
||||
}
|
||||
s.activity(r, t.ProjectID, "", "task.deleted", map[string]any{"content": t.Content})
|
||||
@@ -427,7 +434,7 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
current, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
|
||||
@@ -449,12 +456,12 @@ func (s *Server) handleMoveTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if _, err := s.db.MoveTask(r.Context(), taskID, projectID, req.SectionID, req.AfterID, req.BeforeID); err != nil {
|
||||
s.storeError(w, "move task", err)
|
||||
s.storeError(w, r, "move task", err)
|
||||
return
|
||||
}
|
||||
t, err := s.db.GetTask(r.Context(), taskID, user.ID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get task", err)
|
||||
s.storeError(w, r, "get task", err)
|
||||
return
|
||||
}
|
||||
s.publish(t.ProjectID, "task.moved", toTaskJSON(*t))
|
||||
@@ -468,6 +475,10 @@ type quickAddRequest struct {
|
||||
// ProjectID is the view the user was looking at. A "#project" in the text
|
||||
// wins over it: what somebody typed beats where they happened to be standing.
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
// SectionID is the section the box was opened in, for the field that sits at
|
||||
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
||||
// because typing it is a choice and standing there is a circumstance.
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -502,7 +513,7 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if projectID == "" {
|
||||
var err error
|
||||
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
|
||||
s.internal(w, "inbox", err)
|
||||
s.internal(w, r, "inbox", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -511,9 +522,31 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolved after the project, because a section belongs to one project and the
|
||||
// same name can exist in several. A name that matches nothing is reported the
|
||||
// same way an unknown project is: the task is still created, in the project
|
||||
// without a section, and the caller is told which word did not land.
|
||||
sectionID, unknownSection := "", ""
|
||||
// The section the box belongs to, unless the text names one. Checked against
|
||||
// the project the task actually landed in, so a "#project" that moved it
|
||||
// elsewhere cannot drag a section id from another project along with it.
|
||||
if req.SectionID != "" {
|
||||
if belongs, err := s.db.SectionInProject(r.Context(), req.SectionID, projectID); err == nil && belongs {
|
||||
sectionID = req.SectionID
|
||||
}
|
||||
}
|
||||
if parsed.Section != "" {
|
||||
if id, err := s.db.SectionByName(r.Context(), projectID, parsed.Section); err == nil {
|
||||
sectionID = id
|
||||
} else {
|
||||
unknownSection = parsed.Section
|
||||
}
|
||||
}
|
||||
|
||||
t := &store.Task{
|
||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||
CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||
ProjectID: projectID, SectionID: sectionID, Content: parsed.Content,
|
||||
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence,
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||
@@ -523,20 +556,21 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.CreateTask(r.Context(), t, parsed.Labels); err != nil {
|
||||
s.internal(w, "create task", err)
|
||||
s.internal(w, r, "create task", err)
|
||||
return
|
||||
}
|
||||
t.Labels = parsed.Labels
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
|
||||
if unknownProject != "" {
|
||||
if unknownProject != "" || unknownSection != "" {
|
||||
// Alongside the task rather than instead of it: the task was created, and
|
||||
// this says what could not be honoured while creating it.
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
taskJSON
|
||||
UnknownProject string `json:"unknown_project"`
|
||||
}{toTaskJSON(*t), unknownProject})
|
||||
UnknownProject string `json:"unknown_project,omitempty"`
|
||||
UnknownSection string `json:"unknown_section,omitempty"`
|
||||
}{toTaskJSON(*t), unknownProject, unknownSection})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
|
||||
@@ -1682,3 +1682,98 @@ func TestNotifications(t *testing.T) {
|
||||
t.Errorf("unread = %v after marking everything read", after["unread"])
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add can put a task straight into a section.
|
||||
//
|
||||
// A section belongs to one project, so the name is resolved after the project is
|
||||
// decided — and two projects may both have a "Produktion", which are different
|
||||
// sections. A name that matches nothing is reported the way an unknown project is:
|
||||
// the task is still created, and the caller is told which word did not land.
|
||||
func TestQuickAddPutsATaskInASection(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Risteriet"})
|
||||
projectID := project["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||
map[string]any{"name": "Kunder/Ordrer"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
// The section name has a slash in it, which is also the sigil. The bare form is
|
||||
// greedy, so the whole name comes across.
|
||||
resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Karpenhøj kaffe bestilling #Risteriet /Kunder/Ordrer"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: status %d, body %v", resp.StatusCode, task)
|
||||
}
|
||||
if task["section_id"] != sectionID {
|
||||
t.Errorf("section_id = %v, want %v", task["section_id"], sectionID)
|
||||
}
|
||||
if task["content"] != "Karpenhøj kaffe bestilling" {
|
||||
t.Errorf("content = %v — the sigils should be gone", task["content"])
|
||||
}
|
||||
|
||||
// Case-insensitive, like the project sigil.
|
||||
_, lower := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": `nye poser #Risteriet /"kunder/ordrer"`})
|
||||
if lower["section_id"] != sectionID {
|
||||
t.Errorf("a differently-cased name did not resolve: %v", lower["section_id"])
|
||||
}
|
||||
|
||||
// A name that matches nothing still creates the task, and says so.
|
||||
_, missing := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "noget andet #Risteriet /Findesikke"})
|
||||
if missing["section_id"] != nil && missing["section_id"] != "" {
|
||||
t.Errorf("an unknown section should leave the task unsectioned: %v", missing["section_id"])
|
||||
}
|
||||
if missing["unknown_section"] != "Findesikke" {
|
||||
t.Errorf("unknown_section = %v, want Findesikke", missing["unknown_section"])
|
||||
}
|
||||
if missing["content"] != "noget andet" {
|
||||
t.Errorf("content = %v", missing["content"])
|
||||
}
|
||||
|
||||
// A section in another project is a different section, and must not be found.
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andet"})
|
||||
ts.do(t, "POST", "/api/v1/projects/"+other["id"].(string)+"/sections",
|
||||
map[string]any{"name": "Egen"})
|
||||
_, crossed := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "forkert sted #Risteriet /Egen"})
|
||||
if crossed["unknown_section"] != "Egen" {
|
||||
t.Errorf("a section from another project was found: %v", crossed["section_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// The field at the foot of a section passes the section it sits in, and a
|
||||
// "#project" in the same line can move the task out from under it.
|
||||
func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, here := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Her"})
|
||||
hereID := here["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+hereID+"/sections",
|
||||
map[string]any{"name": "Produktion"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
// Standing in the section is enough; no sigil needed.
|
||||
_, plain := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "male gavlen", "project_id": hereID, "section_id": sectionID,
|
||||
})
|
||||
if plain["section_id"] != sectionID {
|
||||
t.Errorf("section_id = %v, want %v", plain["section_id"], sectionID)
|
||||
}
|
||||
|
||||
// A "#project" that moves the task must not drag the section id with it: that
|
||||
// section belongs to the project being left behind.
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsted"})
|
||||
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "male gavlen #Andetsted", "project_id": hereID, "section_id": sectionID,
|
||||
})
|
||||
if moved["section_id"] != nil && moved["section_id"] != "" {
|
||||
t.Errorf("the section came along to another project: %v", moved["section_id"])
|
||||
}
|
||||
if moved["content"] != "male gavlen" {
|
||||
t.Errorf("content = %v", moved["content"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ type createdTokenJSON struct {
|
||||
func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||
tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list api tokens", err)
|
||||
s.internal(w, r, "list api tokens", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires)
|
||||
if err != nil {
|
||||
s.internal(w, "create api token", err)
|
||||
s.internal(w, r, "create api token", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete api token", err)
|
||||
s.storeError(w, r, "delete api token", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
@@ -29,12 +29,12 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email)
|
||||
if err != nil {
|
||||
s.internal(w, "generate totp secret", err)
|
||||
s.internal(w, r, "generate totp secret", err)
|
||||
return
|
||||
}
|
||||
// Stored but not enabled: confirm decides that.
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil {
|
||||
s.internal(w, "store totp secret", err)
|
||||
s.internal(w, r, "store totp secret", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri})
|
||||
@@ -63,15 +63,15 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
codes, hashes, err := auth.NewRecoveryCodes()
|
||||
if err != nil {
|
||||
s.internal(w, "generate recovery codes", err)
|
||||
s.internal(w, r, "generate recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
||||
s.internal(w, "store recovery codes", err)
|
||||
s.internal(w, r, "store recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil {
|
||||
s.internal(w, "enable totp", err)
|
||||
s.internal(w, r, "enable totp", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil {
|
||||
s.internal(w, "disable totp", err)
|
||||
s.internal(w, r, "disable totp", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil {
|
||||
@@ -127,11 +127,11 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
||||
|
||||
codes, hashes, err := auth.NewRecoveryCodes()
|
||||
if err != nil {
|
||||
s.internal(w, "generate recovery codes", err)
|
||||
s.internal(w, r, "generate recovery codes", err)
|
||||
return
|
||||
}
|
||||
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
||||
s.internal(w, "store recovery codes", err)
|
||||
s.internal(w, r, "store recovery codes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes})
|
||||
@@ -140,7 +140,7 @@ func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Re
|
||||
func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "count recovery codes", err)
|
||||
s.internal(w, r, "count recovery codes", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"remaining": n})
|
||||
|
||||
@@ -30,11 +30,13 @@ type adminUserJSON struct {
|
||||
Self bool `json:"self"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||
// ProjectCount and TaskCount are what a delete would destroy. Sent with the
|
||||
// list rather than fetched when the button is pressed, so the confirmation can
|
||||
// name real numbers without a round trip in the middle of a decision.
|
||||
ProjectCount int `json:"project_count"`
|
||||
TaskCount int `json:"task_count"`
|
||||
// ProjectCount and TaskCount are what a delete would destroy, and
|
||||
// AuthoredElsewhere is what it would leave behind without an author. Sent with
|
||||
// the list rather than fetched when the button is pressed, so the confirmation
|
||||
// can name real numbers without a round trip in the middle of a decision.
|
||||
ProjectCount int `json:"project_count"`
|
||||
TaskCount int `json:"task_count"`
|
||||
AuthoredElsewhere int `json:"authored_elsewhere"`
|
||||
}
|
||||
|
||||
type pendingInviteJSON struct {
|
||||
@@ -54,12 +56,12 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
users, err := s.db.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "list users", err)
|
||||
s.internal(w, r, "list users", err)
|
||||
return
|
||||
}
|
||||
invites, err := s.db.ListPendingInvites(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "list pending invites", err)
|
||||
s.internal(w, r, "list pending invites", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -70,6 +72,7 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
IsAdmin: u.IsAdmin, Self: u.ID == me.ID,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
ProjectCount: u.ProjectCount, TaskCount: u.TaskCount,
|
||||
AuthoredElsewhere: u.AuthoredElsewhere,
|
||||
}
|
||||
// Zero means never signed in, which is not a time and must not be sent as
|
||||
// one — 1970 in an interface reads as a bug rather than as "never".
|
||||
@@ -128,7 +131,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
token, _, err := s.db.CreateInvite(r.Context(), email, "", store.RoleEditor,
|
||||
inviter.ID, s.cfg.InviteTTL)
|
||||
if err != nil {
|
||||
s.internal(w, "create instance invite", err)
|
||||
s.internal(w, r, "create instance invite", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.BaseURL + "/invite?token=" + token
|
||||
@@ -170,7 +173,7 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.SetUserAdmin(r.Context(), userID, *req.IsAdmin); err != nil {
|
||||
s.storeError(w, "set user admin", err)
|
||||
s.storeError(w, r, "set user admin", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("admin changed", "by", me.ID, "user", userID, "is_admin", *req.IsAdmin)
|
||||
@@ -197,7 +200,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := s.db.DeleteUser(r.Context(), userID); err != nil {
|
||||
s.storeError(w, "delete user", err)
|
||||
s.storeError(w, r, "delete user", err)
|
||||
return
|
||||
}
|
||||
s.log.Info("user deleted", "by", me.ID, "user", userID)
|
||||
@@ -206,7 +209,7 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.DeleteInvite(r.Context(), chi.URLParam(r, "inviteID")); err != nil {
|
||||
s.storeError(w, "delete invite", err)
|
||||
s.storeError(w, r, "delete invite", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -218,7 +221,7 @@ func (s *Server) handleDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userID, message string) error {
|
||||
target, err := s.db.UserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get user", err)
|
||||
s.storeError(w, r, "get user", err)
|
||||
return err
|
||||
}
|
||||
if !target.IsAdmin {
|
||||
@@ -226,7 +229,7 @@ func (s *Server) refuseIfLastAdmin(w http.ResponseWriter, r *http.Request, userI
|
||||
}
|
||||
admins, err := s.db.CountAdmins(r.Context())
|
||||
if err != nil {
|
||||
s.internal(w, "count admins", err)
|
||||
s.internal(w, r, "count admins", err)
|
||||
return err
|
||||
}
|
||||
if admins <= 1 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -125,8 +126,8 @@ func TestASecondAdministratorCanBeDemoted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting an account takes more than its own projects, and the list says so
|
||||
// before anybody presses the button.
|
||||
// Deleting an account takes its own projects and leaves everything else behind.
|
||||
// The list says both numbers before anybody presses the button.
|
||||
func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
@@ -143,9 +144,10 @@ func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
||||
"content": "deres egen", "project_id": theirs["id"].(string),
|
||||
})
|
||||
|
||||
// And a task they wrote in a project somebody else owns. tasks.created_by
|
||||
// cascades, so this one goes too — and a count that missed it would understate
|
||||
// the damage in exactly the case that matters.
|
||||
// And a task they wrote in a project somebody else owns. This one survives —
|
||||
// `created_by` is ON DELETE SET NULL — and it is counted separately, because
|
||||
// "this much disappears" and "this much stays unattributed" are two different
|
||||
// things to tell somebody who is about to press delete.
|
||||
_, mine := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
|
||||
sharedID := mine["id"].(string)
|
||||
ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{
|
||||
@@ -168,18 +170,52 @@ func TestTheDeleteCountsSayWhatWouldGo(t *testing.T) {
|
||||
if found["project_count"] != float64(1) {
|
||||
t.Errorf("project_count = %v, want 1 — the Inbox must not be counted", found["project_count"])
|
||||
}
|
||||
if found["task_count"] != float64(2) {
|
||||
t.Errorf("task_count = %v, want 2 — the task in the shared project cascades too",
|
||||
if found["task_count"] != float64(1) {
|
||||
t.Errorf("task_count = %v, want 1 — only the task in their own project goes",
|
||||
found["task_count"])
|
||||
}
|
||||
if found["authored_elsewhere"] != float64(1) {
|
||||
t.Errorf("authored_elsewhere = %v, want 1 — the task in the shared project stays",
|
||||
found["authored_elsewhere"])
|
||||
}
|
||||
|
||||
// And the delete really does take it.
|
||||
// And the delete leaves it standing, without an author.
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/users/"+otherUser.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: status %d", resp.StatusCode)
|
||||
}
|
||||
_, tasks := ts.do(t, "GET", "/api/v1/tasks?project_id="+sharedID, nil)
|
||||
if got, _ := tasks["tasks"].([]any); len(got) != 0 {
|
||||
t.Errorf("a task written by the deleted account survived in a shared project: %v", got)
|
||||
got, _ := tasks["tasks"].([]any)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("a task written by the deleted account should survive in a shared project, got %v", got)
|
||||
}
|
||||
surviving := got[0].(map[string]any)
|
||||
if surviving["content"] != "skrevet i et delt projekt" {
|
||||
t.Errorf("the surviving task is not the one that was written: %v", surviving["content"])
|
||||
}
|
||||
if surviving["created_by"] != "" {
|
||||
t.Errorf("created_by = %q, want empty — the author is gone, the work is not",
|
||||
surviving["created_by"])
|
||||
}
|
||||
}
|
||||
|
||||
// The tasks table was rebuilt to change created_by's delete action, and a rebuild
|
||||
// is where an index quietly stops matching its table: tasks_fts is external-content
|
||||
// and keyed on tasks.rowid, which a rebuilt table hands out afresh. Search is the
|
||||
// only place that would notice.
|
||||
func TestSearchStillFindsTasksAfterTheTableRebuild(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Have"})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "beskær det grønne hegn", "project_id": project["id"].(string),
|
||||
})
|
||||
|
||||
for _, q := range []string{"grønne", "gronne", "hegn"} {
|
||||
_, found := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(q), nil)
|
||||
if got, _ := found["tasks"].([]any); len(got) != 1 {
|
||||
t.Errorf("search for %q found %d tasks, want 1", q, len(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,3 +313,70 @@ func indexAfter(s, needle string) int {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// A 500 has to leave something behind that a restart cannot take.
|
||||
//
|
||||
// The panel's watcher already reports that one happened. What it cannot report is
|
||||
// what it was: that lives in the container's log, and a Rune replaces the
|
||||
// container on every restart, so the explanation is usually gone before anybody
|
||||
// looks.
|
||||
func TestAServerErrorIsKeptWhereARestartCannotTakeIt(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Nothing has gone wrong yet.
|
||||
_, empty := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
if rows, _ := empty["errors"].([]any); len(rows) != 0 {
|
||||
t.Fatalf("a fresh instance has errors already: %v", rows)
|
||||
}
|
||||
|
||||
// Break something the handler cannot survive: the table it reads is gone, so
|
||||
// the query fails and the handler answers 500. Dropping it is a blunt way to
|
||||
// provoke that, and a faithful one — it is the same code path a disk error or
|
||||
// a corrupt page would take.
|
||||
if _, err := ts.db.ExecContext(t.Context(), `DROP TABLE project_groups`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, _ := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status %d, want 500 — the fault was not provoked", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, body := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
rows, _ := body["errors"].([]any)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want one recorded error, got %v", rows)
|
||||
}
|
||||
row := rows[0].(map[string]any)
|
||||
if row["what"] != "list project groups" {
|
||||
t.Errorf("what = %v — the operation is the part a status code cannot give", row["what"])
|
||||
}
|
||||
if row["path"] != "/api/v1/project-groups" || row["method"] != "GET" {
|
||||
t.Errorf("path/method = %v %v", row["method"], row["path"])
|
||||
}
|
||||
if row["status"] != float64(500) {
|
||||
t.Errorf("status = %v", row["status"])
|
||||
}
|
||||
if row["message"] == "" {
|
||||
t.Error("no message: the row says something broke and not what")
|
||||
}
|
||||
if row["user_name"] != "Kristian" {
|
||||
t.Errorf("user_name = %v — a fault only one account hits is a different problem",
|
||||
row["user_name"])
|
||||
}
|
||||
if row["request_id"] == "" {
|
||||
t.Error("no request id, so the row cannot be tied back to the log line")
|
||||
}
|
||||
}
|
||||
|
||||
// The list is an administrator's: it carries paths, accounts and the server's own
|
||||
// error strings.
|
||||
func TestTheErrorLogIsAdminsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
if resp, _ := other.do(t, "GET", "/api/v1/errors", nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: 500,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "today", err)
|
||||
s.internal(w, r, "today", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "upcoming", err)
|
||||
s.internal(w, r, "upcoming", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (s *Server) handleUpcoming(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handlePeople(w http.ResponseWriter, r *http.Request) {
|
||||
people, err := s.db.ListPeople(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list people", err)
|
||||
s.internal(w, r, "list people", err)
|
||||
return
|
||||
}
|
||||
out := make([]personJSON, 0, len(people))
|
||||
@@ -142,7 +142,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "delegated", err)
|
||||
s.internal(w, r, "delegated", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ func (s *Server) handleDelegated(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
people, err := s.db.PeopleByIDs(r.Context(), ids)
|
||||
if err != nil {
|
||||
s.internal(w, "delegated people", err)
|
||||
s.internal(w, r, "delegated people", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "search tasks", err)
|
||||
s.internal(w, r, "search tasks", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
// second FTS index for that would be more machinery than the problem needs.
|
||||
projects, err := s.db.ListProjects(r.Context(), user.ID, false)
|
||||
if err != nil {
|
||||
s.internal(w, "search projects", err)
|
||||
s.internal(w, r, "search projects", err)
|
||||
return
|
||||
}
|
||||
matched := []projectJSON{}
|
||||
@@ -239,7 +239,7 @@ func (s *Server) handleActivity(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := s.db.ListActivity(r.Context(), chi.URLParam(r, "projectID"),
|
||||
parseLimit(r.URL.Query().Get("limit"), 50, 200))
|
||||
if err != nil {
|
||||
s.internal(w, "list activity", err)
|
||||
s.internal(w, r, "list activity", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package i18n exists only to hold this test.
|
||||
//
|
||||
// The interface's strings live in the frontend, where they are read; nothing in Go
|
||||
// needs them. What Go can do is fail the build when the two dictionaries drift,
|
||||
// which is the failure mode a translation always has and which nothing else here
|
||||
// would notice: a key present in Danish and missing from English renders as Danish
|
||||
// inside an English screen, and a key present only in English is a string nobody
|
||||
// will ever see.
|
||||
//
|
||||
// A scanner rather than a parser, the same approach `colors_test.go` and
|
||||
// `openapi_test.go` take: it needs the keys out of two lists this repository owns,
|
||||
// and that is worth more than a dependency in a project whose whole shape is "one
|
||||
// static binary".
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheTwoDictionariesHaveTheSameKeys(t *testing.T) {
|
||||
dir := filepath.Join("..", "..", "web", "src", "lib", "locales")
|
||||
da := readKeys(t, filepath.Join(dir, "da.js"))
|
||||
en := readKeys(t, filepath.Join(dir, "en.js"))
|
||||
|
||||
if len(da) == 0 {
|
||||
t.Fatal("no keys found in da.js — the scanner is looking at the wrong shape")
|
||||
}
|
||||
|
||||
var missing, extra []string
|
||||
for key := range da {
|
||||
if !en[key] {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
for key := range en {
|
||||
if !da[key] {
|
||||
extra = append(extra, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("%d keys are in da.js and not in en.js — they render as Danish inside an "+
|
||||
"English screen:\n %s", len(missing), strings.Join(missing, "\n "))
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
t.Errorf("%d keys are in en.js and not in da.js — nothing reads them, because Danish "+
|
||||
"is the source:\n %s", len(extra), strings.Join(extra, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// The placeholders have to match too. `t()` fills `{name}` from what the caller
|
||||
// passes, so a translation that spells one differently leaves `{navn}` on the
|
||||
// screen — which is worse than an untranslated sentence, because it looks like a
|
||||
// bug in the app rather than a gap in the dictionary.
|
||||
func TestTheTwoDictionariesUseTheSamePlaceholders(t *testing.T) {
|
||||
dir := filepath.Join("..", "..", "web", "src", "lib", "locales")
|
||||
da := readEntries(t, filepath.Join(dir, "da.js"))
|
||||
en := readEntries(t, filepath.Join(dir, "en.js"))
|
||||
|
||||
for key, danish := range da {
|
||||
english, ok := en[key]
|
||||
if !ok {
|
||||
continue // reported by the test above
|
||||
}
|
||||
want := placeholders(danish)
|
||||
got := placeholders(english)
|
||||
if strings.Join(want, ",") != strings.Join(got, ",") {
|
||||
t.Errorf("%s: Danish fills %v, English fills %v", key, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// `'some.key': 'the string',` — single-quoted keys and values, which is what
|
||||
// prettier writes for these files.
|
||||
reEntry = regexp.MustCompile(`(?m)^\s*'([a-zA-Z0-9._]+)':\s*(.*?),?\s*$`)
|
||||
rePlaceholder = regexp.MustCompile(`\{(\w+)\}`)
|
||||
)
|
||||
|
||||
func readKeys(t *testing.T, path string) map[string]bool {
|
||||
t.Helper()
|
||||
keys := map[string]bool{}
|
||||
for key := range readEntries(t, path) {
|
||||
keys[key] = true
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func readEntries(t *testing.T, path string) map[string]string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", filepath.Base(path), err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, m := range reEntry.FindAllStringSubmatch(string(body), -1) {
|
||||
out[m[1]] = m[2]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func placeholders(s string) []string {
|
||||
var found []string
|
||||
for _, m := range rePlaceholder.FindAllStringSubmatch(s, -1) {
|
||||
found = append(found, m[1])
|
||||
}
|
||||
sort.Strings(found)
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Danish prose left in a component is the failure the dictionaries cannot see.
|
||||
//
|
||||
// `i18n_test.go` checks that the two dictionaries agree. It cannot check the thing
|
||||
// that actually went wrong: a string that was never put in either, because nobody
|
||||
// noticed it. Three survived the sweep — "Ingen nyere version fundet.", "Ikke mere
|
||||
// i dag." and a token hint split across two lines — and they survived it for the
|
||||
// same reason. The sweep grepped for æøå, and none of them contain one.
|
||||
//
|
||||
// So this reads the templates instead and looks for the words that give Danish
|
||||
// away whatever letters it happens to use. A word list rather than a language
|
||||
// detector: this repository ships one static binary, and a list of thirty common
|
||||
// words catches the sentences a person would actually write.
|
||||
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
||||
// Text nodes of three characters or more, and the three attributes a person
|
||||
// reads. Anything inside {…} is already an expression — usually a t() call —
|
||||
// and is not what this is looking for.
|
||||
text := regexp.MustCompile(`>\s*([^<>{}\n][^<>{}]{3,})\s*<`)
|
||||
attrs := regexp.MustCompile(`(?:aria-label|title|placeholder)="([^"{][^"]{3,})"`)
|
||||
// Words that are Danish and are not also English. "is", "for" and "to" would
|
||||
// match English prose and are deliberately absent.
|
||||
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb)\b`)
|
||||
comments := regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
styles := regexp.MustCompile(`(?s)<style>.*`)
|
||||
|
||||
var found []string
|
||||
root := filepath.Join("..", "..", "web", "src")
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".svelte") {
|
||||
return err
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
markup := string(body)
|
||||
if at := strings.Index(markup, "</script>"); at > 0 {
|
||||
markup = markup[at:]
|
||||
}
|
||||
markup = styles.ReplaceAllString(comments.ReplaceAllString(markup, ""), "")
|
||||
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
for _, m := range append(text.FindAllStringSubmatch(markup, -1),
|
||||
attrs.FindAllStringSubmatch(markup, -1)...) {
|
||||
phrase := strings.TrimSpace(m[1])
|
||||
if danish.MatchString(phrase) {
|
||||
found = append(found, rel+": "+strings.Join(strings.Fields(phrase), " "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
|
||||
if len(found) > 0 {
|
||||
t.Errorf("%d Danish string(s) are written into a component instead of going through "+
|
||||
"t(). An English interface shows them in Danish, and neither dictionary knows "+
|
||||
"they exist:\n %s", len(found), strings.Join(found, "\n "))
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func (r *Runner) Start(ctx context.Context) {
|
||||
r.every(ctx, "backup", time.Hour, r.nightlyBackup)
|
||||
r.every(ctx, "trash", time.Hour, r.emptyTrash)
|
||||
r.every(ctx, "sessions", 6*time.Hour, r.purgeSessions)
|
||||
r.every(ctx, "errors", 12*time.Hour, r.purgeErrors)
|
||||
// Ten minutes is a compromise: Gmail's push notifications would be instant but
|
||||
// need a public webhook and a Cloud Pub/Sub topic, which is a lot of Google
|
||||
// account for a to-do app. Ten minutes is fast enough for something somebody
|
||||
@@ -161,22 +162,41 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
||||
if time.Since(last) < 23*time.Hour {
|
||||
return nil
|
||||
}
|
||||
return Backup(ctx, r.cfg, r.db, r.log)
|
||||
}
|
||||
|
||||
// Backup takes one snapshot now, records the run and rotates the old files.
|
||||
//
|
||||
// Exported and free of the Runner, because the settings page can ask for one too:
|
||||
// waiting until tonight to find out whether backups work at all is how somebody
|
||||
// discovers on the day they need one that the volume has been read-only for a
|
||||
// month. The nightly job is this function behind a "has one run today" check; the
|
||||
// button is this function without it.
|
||||
//
|
||||
// It uses SQLite's own VACUUM INTO rather than copying the file. A plain copy of a
|
||||
// live WAL-mode database can miss the most recent writes or catch a page
|
||||
// mid-update; VACUUM INTO produces a consistent, already-compacted database
|
||||
// without blocking writers for the duration.
|
||||
func Backup(ctx context.Context, cfg *config.Config, db *store.DB, log *slog.Logger) error {
|
||||
started := time.Now()
|
||||
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
|
||||
path := filepath.Join(r.cfg.BackupsDir(), name)
|
||||
path := filepath.Join(cfg.BackupsDir(), name)
|
||||
|
||||
runID, err := r.db.StartBackupRun(ctx, started)
|
||||
if err := os.MkdirAll(cfg.BackupsDir(), 0o755); err != nil {
|
||||
return fmt.Errorf("backups dir: %w", err)
|
||||
}
|
||||
|
||||
runID, err := db.StartBackupRun(ctx, started)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.db.Snapshot(ctx, path); err != nil {
|
||||
if err := db.Snapshot(ctx, path); err != nil {
|
||||
// A failed attempt can leave a partial file, which would then look like a
|
||||
// backup to anybody reading the directory.
|
||||
os.Remove(path)
|
||||
if recErr := r.db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
|
||||
r.log.Warn("record backup failure", "err", recErr)
|
||||
if recErr := db.FinishBackupRun(ctx, runID, "", 0, err); recErr != nil {
|
||||
log.Warn("record backup failure", "err", recErr)
|
||||
}
|
||||
return fmt.Errorf("snapshot: %w", err)
|
||||
}
|
||||
@@ -185,13 +205,13 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
||||
r.log.Warn("record backup", "err", err)
|
||||
if err := db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
||||
log.Warn("record backup", "err", err)
|
||||
}
|
||||
r.log.Info("backup written", "path", path, "bytes", size,
|
||||
log.Info("backup written", "path", path, "bytes", size,
|
||||
"took", time.Since(started).Round(time.Millisecond).String())
|
||||
|
||||
return r.rotateBackups()
|
||||
return rotateBackups(cfg, log)
|
||||
}
|
||||
|
||||
// rotateBackups keeps the most recent fourteen and deletes the rest.
|
||||
@@ -199,8 +219,8 @@ func (r *Runner) nightlyBackup(ctx context.Context) error {
|
||||
// Counted by file, not by age: a container that was off for a month would
|
||||
// otherwise come back, find every backup older than fourteen days, and delete all
|
||||
// of them — leaving none at exactly the moment they were most likely to be needed.
|
||||
func (r *Runner) rotateBackups() error {
|
||||
entries, err := os.ReadDir(r.cfg.BackupsDir())
|
||||
func rotateBackups(cfg *config.Config, log *slog.Logger) error {
|
||||
entries, err := os.ReadDir(cfg.BackupsDir())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -215,12 +235,12 @@ func (r *Runner) rotateBackups() error {
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(backups)))
|
||||
|
||||
for _, name := range backups[min(len(backups), backupKeepDays):] {
|
||||
path := filepath.Join(r.cfg.BackupsDir(), name)
|
||||
path := filepath.Join(cfg.BackupsDir(), name)
|
||||
if err := os.Remove(path); err != nil {
|
||||
r.log.Warn("remove old backup", "err", err, "path", path)
|
||||
log.Warn("remove old backup", "err", err, "path", path)
|
||||
continue
|
||||
}
|
||||
r.log.Info("old backup removed", "path", path)
|
||||
log.Info("old backup removed", "path", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -265,6 +285,20 @@ func (r *Runner) emptyTrash(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// purgeErrors keeps the diagnostic bounded. Thirty days is long enough to answer
|
||||
// "what happened last week" and short enough that a fault loop cannot fill the
|
||||
// volume the tasks live on.
|
||||
func (r *Runner) purgeErrors(ctx context.Context) error {
|
||||
n, err := r.db.PurgeOldErrors(ctx, 30*24*time.Hour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
r.log.Debug("old error rows removed", "count", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) purgeSessions(ctx context.Context) error {
|
||||
n, err := r.db.PurgeExpiredSessions(ctx)
|
||||
if err != nil {
|
||||
@@ -276,27 +310,12 @@ func (r *Runner) purgeSessions(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunBackupNow is what the "back up now" button calls, and what the tests use.
|
||||
// RunBackupNow is the Runner's way in, for the tests that already hold one.
|
||||
//
|
||||
// It was a second copy of Backup — written when the button that would call it did
|
||||
// not exist yet — and the two had already drifted: this one never created the
|
||||
// backups directory, and logged nothing. One implementation, so a fix to the
|
||||
// nightly path is a fix to the button as well.
|
||||
func (r *Runner) RunBackupNow(ctx context.Context) error {
|
||||
started := time.Now()
|
||||
name := backupPrefix + started.UTC().Format("2006-01-02T150405Z") + ".db"
|
||||
path := filepath.Join(r.cfg.BackupsDir(), name)
|
||||
|
||||
runID, err := r.db.StartBackupRun(ctx, started)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.db.Snapshot(ctx, path); err != nil {
|
||||
os.Remove(path)
|
||||
_ = r.db.FinishBackupRun(ctx, runID, "", 0, err)
|
||||
return err
|
||||
}
|
||||
var size int64
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
if err := r.db.FinishBackupRun(ctx, runID, path, size, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.rotateBackups()
|
||||
return Backup(ctx, r.cfg, r.db, r.log)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ func TestBackupsRotateByCountNotAge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := runner.rotateBackups(); err != nil {
|
||||
if err := rotateBackups(cfg, runner.log); err != nil {
|
||||
t.Fatalf("rotate: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
KindTime Kind = "time"
|
||||
KindPriority Kind = "priority"
|
||||
KindProject Kind = "project"
|
||||
KindSection Kind = "section"
|
||||
KindLabel Kind = "label"
|
||||
KindRepeat Kind = "repeat"
|
||||
)
|
||||
@@ -52,7 +53,11 @@ type Result struct {
|
||||
Content string `json:"content"`
|
||||
// Project is the name written after '#', not an id: the caller resolves it
|
||||
// against the user's projects and decides what to do when it matches nothing.
|
||||
Project string `json:"project,omitempty"`
|
||||
Project string `json:"project,omitempty"`
|
||||
// Section is the name written after '/', resolved by the caller against the
|
||||
// sections of whichever project the task lands in. A section belongs to one
|
||||
// project, so it cannot be looked up until the project is known.
|
||||
Section string `json:"section,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
// Priority is 1 (highest) to 4. 4 is both the default and "none stated".
|
||||
Priority int `json:"priority"`
|
||||
@@ -81,6 +86,7 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
// Sigils first. '#' and '@' are unambiguous, and consuming them stops a project
|
||||
// called "Fredag" from later being read as a weekday.
|
||||
res.Project, spans = extractProject(input, spans)
|
||||
res.Section, spans = extractSection(input, spans)
|
||||
res.Labels, spans = extractLabels(input, spans)
|
||||
res.Priority, spans = extractPriority(input, spans)
|
||||
|
||||
@@ -144,6 +150,16 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
var (
|
||||
reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
reLabel = regexp.MustCompile(`(?:^|\s)@(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
// A section, written '/Produktion' or '/"Kunder og ordrer"'.
|
||||
//
|
||||
// The name may itself contain a slash — "Kunder/Ordrer" is a real section
|
||||
// name — so the bare form is greedy and swallows the lot.
|
||||
//
|
||||
// The leading `(?:^|\s)` is what keeps this out of ordinary text. A slash
|
||||
// inside a word never matches, so "km/t" and "3/4" are safe, and the name has
|
||||
// to start immediately after the slash, so "20 kr / stk" is safe too. Those
|
||||
// three are the shapes a slash actually takes in a Danish task line.
|
||||
reSection = regexp.MustCompile(`(?:^|\s)/(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||
)
|
||||
|
||||
func extractProject(input string, spans []Span) (string, []Span) {
|
||||
@@ -160,6 +176,20 @@ func extractProject(input string, spans []Span) (string, []Span) {
|
||||
return name, spans
|
||||
}
|
||||
|
||||
// Last one wins, as with the project: retyping is a correction, not two sections.
|
||||
func extractSection(input string, spans []Span) (string, []Span) {
|
||||
all := reSection.FindAllStringSubmatchIndex(input, -1)
|
||||
if len(all) == 0 {
|
||||
return "", spans
|
||||
}
|
||||
var name string
|
||||
for _, m := range all {
|
||||
name = submatch(input, m)
|
||||
spans = append(spans, sigilSpan(input, m, KindSection))
|
||||
}
|
||||
return name, spans
|
||||
}
|
||||
|
||||
func extractLabels(input string, spans []Span) ([]string, []Span) {
|
||||
var labels []string
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -560,3 +560,46 @@ func TestRecurrenceIsHighlighted(t *testing.T) {
|
||||
t.Error("no span of kind repeat")
|
||||
}
|
||||
}
|
||||
|
||||
// A section, and the three shapes a slash takes in ordinary text.
|
||||
//
|
||||
// The sigil is the one that needed thinking about: '#' and '@' never occur in a
|
||||
// Danish task line by accident, and '/' occurs constantly. What keeps it apart is
|
||||
// that the slash must follow whitespace and the name must follow the slash with
|
||||
// nothing in between — which "km/t", "3/4" and "20 kr / stk" all fail.
|
||||
func TestASectionIsWrittenWithASlash(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
section string
|
||||
content string
|
||||
}{
|
||||
{"ring til Karpenhøj /Produktion", "Produktion", "ring til Karpenhøj"},
|
||||
{"/Produktion ring til Karpenhøj", "Produktion", "ring til Karpenhøj"},
|
||||
// A real section name with a slash in it. The bare form is greedy, so the
|
||||
// whole thing comes across rather than stopping at the second slash.
|
||||
{"nye poser /Kunder/Ordrer", "Kunder/Ordrer", "nye poser"},
|
||||
{`nye poser /"Kunder og ordrer"`, "Kunder og ordrer", "nye poser"},
|
||||
// Retyping is a correction, not two sections.
|
||||
{"nye poser /Drift /Produktion", "Produktion", "nye poser"},
|
||||
// And alongside everything else.
|
||||
{"betal moms i morgen kl 10 p1 #Firma /Bogholderi", "Bogholderi", "betal moms"},
|
||||
|
||||
// The ways a slash shows up when nobody meant a section. None of them may
|
||||
// produce one: the slash has to follow whitespace, and the name has to
|
||||
// follow the slash with nothing between.
|
||||
{"kør 80 km/t forbi", "", "kør 80 km/t forbi"},
|
||||
{"20 kr / stk hos grossisten", "", "20 kr / stk hos grossisten"},
|
||||
// "3/4" is a section to nobody and a date to a Dane — the 3rd of April —
|
||||
// and the date parser is right to claim it. What matters here is only that
|
||||
// it did not become a section.
|
||||
{"læs 3/4 af rapporten", "", "læs af rapporten"},
|
||||
} {
|
||||
got := Parse(c.in, ref(t), "da")
|
||||
if got.Section != c.section {
|
||||
t.Errorf("Parse(%q).Section = %q, want %q", c.in, got.Section, c.section)
|
||||
}
|
||||
if got.Content != c.content {
|
||||
t.Errorf("Parse(%q).Content = %q, want %q", c.in, got.Content, c.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,19 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
ID string
|
||||
ProjectID string
|
||||
TaskID string
|
||||
// ProjectName is filled by the instance-wide log, where a row without it says
|
||||
// nothing: "renamed a section" needs to say which project's section.
|
||||
ProjectName string
|
||||
TaskID string
|
||||
// UserID and UserName are empty when the account has been deleted. The record
|
||||
// of what was done outlives whoever did it — see migration 0009.
|
||||
UserID string
|
||||
UserName string
|
||||
Event string
|
||||
@@ -19,6 +25,23 @@ type Activity struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// AuditFilter narrows the instance-wide log. Zero values mean "do not filter".
|
||||
type AuditFilter struct {
|
||||
UserID string
|
||||
ProjectID string
|
||||
// Event matches exactly, e.g. "task.completed". The interface offers the set
|
||||
// that actually occurs rather than a text field, so there is no prefix or
|
||||
// wildcard to support here.
|
||||
Event string
|
||||
// Before pages backwards. Keyed on (created_at, id) rather than on created_at
|
||||
// alone: the log is written in whole seconds, so a busy moment puts several
|
||||
// rows on the same timestamp and a cursor that only knew the time would either
|
||||
// skip them or repeat them.
|
||||
BeforeAt int64
|
||||
BeforeID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// RecordActivity appends to a project's history.
|
||||
//
|
||||
// Every mutation writes one, which is what makes the activity log complete rather
|
||||
@@ -35,7 +58,7 @@ func (db *DB) RecordActivity(ctx context.Context, projectID, taskID, userID, eve
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
NewID(), projectID, nullString(taskID), userID, event, encoded, time.Now().Unix())
|
||||
NewID(), projectID, nullString(taskID), nullString(userID), event, encoded, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -43,9 +66,12 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
// LEFT JOIN, not JOIN: a row whose author has been deleted still happened, and
|
||||
// an inner join would drop it from the history silently.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT a.id, a.project_id, a.task_id, a.user_id, u.name, a.event, a.payload_json, a.created_at
|
||||
FROM activity a JOIN users u ON u.id = a.user_id
|
||||
SELECT a.id, a.project_id, a.task_id, COALESCE(a.user_id, ''), COALESCE(u.name, ''),
|
||||
a.event, a.payload_json, a.created_at
|
||||
FROM activity a LEFT JOIN users u ON u.id = a.user_id
|
||||
WHERE a.project_id = ?
|
||||
ORDER BY a.created_at DESC, a.id DESC
|
||||
LIMIT ?`, projectID, limit)
|
||||
@@ -72,6 +98,117 @@ func (db *DB) ListActivity(ctx context.Context, projectID string, limit int) ([]
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAuditLog is the same history read across the whole instance rather than one
|
||||
// project at a time.
|
||||
//
|
||||
// The per-project log answers "what happened here", which is a question a project's
|
||||
// members ask about their own work. This one answers "what has happened on this
|
||||
// server", which only an administrator asks and which nothing in the API could
|
||||
// answer before: ListActivity takes one project id, and an administrator is not
|
||||
// necessarily a member of the project they need to look into.
|
||||
//
|
||||
// Reading is a keyset walk rather than an OFFSET. The log is append-heavy and read
|
||||
// rarely, so a page fetched a minute after the one before it would otherwise be
|
||||
// shifted by everything written in between — showing a row twice and skipping the
|
||||
// one behind it, which is precisely the failure an audit log must not have.
|
||||
func (db *DB) ListAuditLog(ctx context.Context, f AuditFilter) ([]Activity, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
if f.UserID != "" {
|
||||
where = append(where, "a.user_id = ?")
|
||||
args = append(args, f.UserID)
|
||||
}
|
||||
if f.ProjectID != "" {
|
||||
where = append(where, "a.project_id = ?")
|
||||
args = append(args, f.ProjectID)
|
||||
}
|
||||
if f.Event != "" {
|
||||
where = append(where, "a.event = ?")
|
||||
args = append(args, f.Event)
|
||||
}
|
||||
if f.BeforeAt > 0 {
|
||||
// A row value, so the tie-break on id is part of the comparison rather
|
||||
// than a second clause that would have to repeat the timestamp.
|
||||
where = append(where, "(a.created_at, a.id) < (?, ?)")
|
||||
args = append(args, f.BeforeAt, f.BeforeID)
|
||||
}
|
||||
clause := ""
|
||||
if len(where) > 0 {
|
||||
clause = "WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT a.id, a.project_id, p.name, a.task_id,
|
||||
COALESCE(a.user_id, ''), COALESCE(u.name, ''),
|
||||
a.event, a.payload_json, a.created_at
|
||||
FROM activity a
|
||||
JOIN projects p ON p.id = a.project_id
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
`+clause+`
|
||||
ORDER BY a.created_at DESC, a.id DESC
|
||||
LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Activity{}
|
||||
for rows.Next() {
|
||||
var a Activity
|
||||
var taskID sql.NullString
|
||||
var payload string
|
||||
var created int64
|
||||
if err := rows.Scan(&a.ID, &a.ProjectID, &a.ProjectName, &taskID, &a.UserID,
|
||||
&a.UserName, &a.Event, &payload, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.TaskID = taskID.String
|
||||
a.CreatedAt = time.Unix(created, 0).UTC()
|
||||
_ = json.Unmarshal([]byte(payload), &a.Payload)
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AuditEvents is the set of event names the log actually contains, most frequent
|
||||
// first, with how many of each.
|
||||
//
|
||||
// The filter offers these rather than a text field. Event names are an internal
|
||||
// vocabulary — "member.role_changed", not a sentence anybody would guess — so a
|
||||
// field to type them into is a field that returns nothing until you have read the
|
||||
// source. The counts are worth having on their own: they are the shape of what the
|
||||
// instance does.
|
||||
func (db *DB) AuditEvents(ctx context.Context) ([]EventCount, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT event, count(*) FROM activity
|
||||
GROUP BY event ORDER BY count(*) DESC, event`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []EventCount{}
|
||||
for rows.Next() {
|
||||
var e EventCount
|
||||
if err := rows.Scan(&e.Event, &e.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type EventCount struct {
|
||||
Event string
|
||||
Count int
|
||||
}
|
||||
|
||||
// PurgeActivity drops history older than the retention window. An activity log that
|
||||
// grows without limit turns a 10 MB database into a 2 GB one over a couple of years
|
||||
// of ordinary use, on hardware that is somebody's spare machine.
|
||||
|
||||
@@ -21,9 +21,12 @@ type Comment struct {
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
ID string
|
||||
TaskID string
|
||||
CommentID string
|
||||
ID string
|
||||
TaskID string
|
||||
CommentID string
|
||||
// GroupID hangs the file on a project group rather than on any one task in
|
||||
// it: the contract that governs all of "Arbejde", not a step in it.
|
||||
GroupID string
|
||||
Filename string
|
||||
MimeType string
|
||||
Size int64
|
||||
@@ -154,17 +157,17 @@ func (db *DB) CreateAttachment(ctx context.Context, a *Attachment) error {
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO attachments (id, task_id, comment_id, filename, mime_type, size,
|
||||
path, uploaded_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, nullString(a.TaskID), nullString(a.CommentID), a.Filename,
|
||||
a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
|
||||
`INSERT INTO attachments (id, task_id, comment_id, group_id, filename, mime_type,
|
||||
size, path, uploaded_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, nullString(a.TaskID), nullString(a.CommentID), nullString(a.GroupID),
|
||||
a.Filename, a.MimeType, a.Size, a.Path, a.UploadedBy, a.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE task_id = ? ORDER BY created_at`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -173,13 +176,26 @@ func (db *DB) ListTaskAttachments(ctx context.Context, taskID string) ([]Attachm
|
||||
return scanAttachments(rows)
|
||||
}
|
||||
|
||||
// ListGroupAttachments is the documents that belong to a whole group rather than
|
||||
// to anything inside it.
|
||||
func (db *DB) ListGroupAttachments(ctx context.Context, groupID string) ([]Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE group_id = ? ORDER BY created_at`, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAttachments(rows)
|
||||
}
|
||||
|
||||
func (db *DB) attachmentsByComment(ctx context.Context, commentIDs []string) (map[string][]Attachment, error) {
|
||||
args := make([]any, len(commentIDs))
|
||||
for i, id := range commentIDs {
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE comment_id IN (`+placeholders(len(commentIDs))+`)
|
||||
ORDER BY created_at`, args...)
|
||||
if err != nil {
|
||||
@@ -202,14 +218,15 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
|
||||
out := []Attachment{}
|
||||
for rows.Next() {
|
||||
var a Attachment
|
||||
var taskID, commentID sql.NullString
|
||||
var taskID, commentID, groupID sql.NullString
|
||||
var created int64
|
||||
if err := rows.Scan(&a.ID, &taskID, &commentID, &a.Filename, &a.MimeType,
|
||||
if err := rows.Scan(&a.ID, &taskID, &commentID, &groupID, &a.Filename, &a.MimeType,
|
||||
&a.Size, &a.Path, &a.UploadedBy, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.TaskID = taskID.String
|
||||
a.CommentID = commentID.String
|
||||
a.GroupID = groupID.String
|
||||
a.CreatedAt = time.Unix(created, 0).UTC()
|
||||
out = append(out, a)
|
||||
}
|
||||
@@ -218,7 +235,7 @@ func scanAttachments(rows *sql.Rows) ([]Attachment, error) {
|
||||
|
||||
func (db *DB) GetAttachment(ctx context.Context, attachmentID string) (*Attachment, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
`SELECT id, task_id, comment_id, group_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments WHERE id = ?`, attachmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A ServerError is one 500 the API answered, kept so it can be read after the
|
||||
// container that produced it has gone.
|
||||
type ServerError struct {
|
||||
ID string
|
||||
At time.Time
|
||||
Method string
|
||||
Path string
|
||||
Status int
|
||||
What string
|
||||
Message string
|
||||
UserID string
|
||||
UserName string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
// RecordError stores one failure.
|
||||
//
|
||||
// Errors here are dropped rather than returned: this is called from the path that
|
||||
// is already reporting a failure to somebody, and a diagnostic that can itself
|
||||
// fail the request would turn one broken screen into two.
|
||||
func (db *DB) RecordError(ctx context.Context, e ServerError) {
|
||||
var user any
|
||||
if e.UserID != "" {
|
||||
user = e.UserID
|
||||
}
|
||||
_, _ = db.ExecContext(ctx,
|
||||
`INSERT INTO error_log (id, at, method, path, status, what, message, user_id, request_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
NewID(), time.Now().Unix(), e.Method, truncate(e.Path, 500), e.Status,
|
||||
truncate(e.What, 200), truncate(e.Message, 2000), user, truncate(e.RequestID, 100))
|
||||
}
|
||||
|
||||
// ListErrors returns the most recent failures, newest first.
|
||||
func (db *DB) ListErrors(ctx context.Context, limit int) ([]ServerError, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT e.id, e.at, e.method, e.path, e.status, e.what, e.message,
|
||||
COALESCE(e.user_id, ''), COALESCE(u.name, ''), e.request_id
|
||||
FROM error_log e
|
||||
LEFT JOIN users u ON u.id = e.user_id
|
||||
ORDER BY e.at DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []ServerError{}
|
||||
for rows.Next() {
|
||||
var e ServerError
|
||||
var at int64
|
||||
if err := rows.Scan(&e.ID, &at, &e.Method, &e.Path, &e.Status, &e.What,
|
||||
&e.Message, &e.UserID, &e.UserName, &e.RequestID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.At = time.Unix(at, 0).UTC()
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PurgeOldErrors is run by the nightly sweep. A diagnostic that grows without
|
||||
// limit is a disk that fills for a reason nobody expected.
|
||||
func (db *DB) PurgeOldErrors(ctx context.Context, keep time.Duration) (int64, error) {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM error_log WHERE at < ?`, time.Now().Add(-keep).Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Server errors, kept where they can outlive the container that produced them.
|
||||
--
|
||||
-- verdande already logs the real cause of every 500 to stdout. On a Rune that is
|
||||
-- the container's log, and every restart starts a new one — so the panel can
|
||||
-- report "HTTP 5xx, twice, at 11:49" long after the line explaining it has gone.
|
||||
-- An operator is then told that something broke and given no way to find out what.
|
||||
--
|
||||
-- Bounded by the sweeper rather than by size: this is a diagnostic, and one that
|
||||
-- grows without limit is a disk that fills for a reason nobody expected.
|
||||
CREATE TABLE error_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
at INTEGER NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
-- what: the operation the handler was attempting, in the words it uses in the
|
||||
-- log — "list projects", "move task". The detail a stack trace would give and
|
||||
-- a status code cannot.
|
||||
what TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
-- Whose request it was, so a fault that only one account hits is visible as
|
||||
-- one. NULL for anything that failed before authentication.
|
||||
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
request_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX idx_error_log_at ON error_log (at DESC);
|
||||
@@ -0,0 +1,101 @@
|
||||
-- `tasks.created_by` stops cascading.
|
||||
--
|
||||
-- It was ON DELETE CASCADE, which made deleting an account delete every task that
|
||||
-- account ever wrote — including in projects somebody else owns. Retiring a
|
||||
-- colleague therefore removed their contributions from shared work rather than
|
||||
-- leaving it behind. It is the last remaining way to lose other people's work by
|
||||
-- accident, and the administrator's page saying so in plain words is a warning, not
|
||||
-- a fix.
|
||||
--
|
||||
-- SET NULL instead: the task stays and loses its author. `created_by` becomes
|
||||
-- nullable to make that expressible, which is the same shape `assignee_id` and
|
||||
-- `completed_by` already have for the same reason.
|
||||
--
|
||||
-- SQLite cannot alter a foreign key action, so the table is rebuilt. The FTS
|
||||
-- triggers come along: they are dropped first so that DROP TABLE and ALTER TABLE
|
||||
-- see a schema they can parse, and recreated below. `tasks_fts` is an
|
||||
-- external-content table keyed on `tasks.rowid`, and a rebuilt table hands out new
|
||||
-- rowids, so the index is rebuilt at the end rather than left pointing at rows that
|
||||
-- have moved.
|
||||
--
|
||||
-- verdande:rebuild-tables
|
||||
|
||||
DROP TRIGGER tasks_fts_insert;
|
||||
DROP TRIGGER tasks_fts_delete;
|
||||
DROP TRIGGER tasks_fts_update;
|
||||
|
||||
CREATE TABLE tasks_rebuilt (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
section_id TEXT REFERENCES sections (id) ON DELETE SET NULL,
|
||||
parent_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 4,
|
||||
due_date TEXT,
|
||||
due_datetime INTEGER,
|
||||
due_timezone TEXT,
|
||||
duration_min INTEGER,
|
||||
recurrence_rule TEXT,
|
||||
assignee_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
-- Nullable, and SET NULL: an account can go without its work going with it.
|
||||
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
completed_at INTEGER,
|
||||
completed_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
sort_order REAL NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
fold TEXT GENERATED ALWAYS AS (
|
||||
replace(replace(replace(replace(replace(replace(
|
||||
content || ' ' || description,
|
||||
'ø', 'o'), 'Ø', 'o'),
|
||||
'æ', 'ae'), 'Æ', 'ae'),
|
||||
'å', 'aa'), 'Å', 'aa')
|
||||
) STORED,
|
||||
CHECK (priority BETWEEN 1 AND 4)
|
||||
);
|
||||
|
||||
INSERT INTO tasks_rebuilt
|
||||
(id, project_id, section_id, parent_id, content, description, priority,
|
||||
due_date, due_datetime, due_timezone, duration_min, recurrence_rule,
|
||||
assignee_id, created_by, completed_at, completed_by, sort_order,
|
||||
created_at, updated_at, deleted_at)
|
||||
SELECT
|
||||
id, project_id, section_id, parent_id, content, description, priority,
|
||||
due_date, due_datetime, due_timezone, duration_min, recurrence_rule,
|
||||
assignee_id, created_by, completed_at, completed_by, sort_order,
|
||||
created_at, updated_at, deleted_at
|
||||
FROM tasks;
|
||||
|
||||
DROP TABLE tasks;
|
||||
|
||||
ALTER TABLE tasks_rebuilt RENAME TO tasks;
|
||||
|
||||
CREATE INDEX idx_tasks_project ON tasks (project_id, sort_order)
|
||||
WHERE deleted_at IS NULL AND completed_at IS NULL;
|
||||
CREATE INDEX idx_tasks_due ON tasks (due_date)
|
||||
WHERE deleted_at IS NULL AND completed_at IS NULL AND due_date IS NOT NULL;
|
||||
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id)
|
||||
WHERE deleted_at IS NULL AND completed_at IS NULL AND assignee_id IS NOT NULL;
|
||||
CREATE INDEX idx_tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER tasks_fts_insert AFTER INSERT ON tasks BEGIN
|
||||
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
|
||||
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tasks_fts_delete AFTER DELETE ON tasks BEGIN
|
||||
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
|
||||
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tasks_fts_update AFTER UPDATE ON tasks BEGIN
|
||||
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
|
||||
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
|
||||
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
|
||||
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
|
||||
END;
|
||||
|
||||
-- The rowids the index was built against are gone with the old table.
|
||||
INSERT INTO tasks_fts (tasks_fts) VALUES ('rebuild');
|
||||
@@ -0,0 +1,54 @@
|
||||
-- `activity.user_id` stops cascading, and the log gets an index it can be read by.
|
||||
--
|
||||
-- Two changes with the same cause: there is now an instance-wide audit log, and
|
||||
-- the table it reads was only ever built to answer "what happened in this
|
||||
-- project".
|
||||
--
|
||||
-- The cascade first. An audit log whose rows disappear when the account they
|
||||
-- describe is deleted answers "what has happened on this server" with everything
|
||||
-- except the part somebody had a reason to remove. That is the wrong answer to the
|
||||
-- question, and it is the same mistake migration 0008 fixed on `tasks.created_by`:
|
||||
-- the record outlives the person. SET NULL, and the reader shows "en slettet
|
||||
-- konto" rather than a name.
|
||||
--
|
||||
-- `project_id` keeps cascading on purpose. A project purged from the trash is
|
||||
-- genuinely gone — the trash is the undo, and past it the deletion was deliberate
|
||||
-- and complete. Activity has its own retention window besides.
|
||||
--
|
||||
-- Then the index. `idx_activity_project` leads with project_id, so a query that
|
||||
-- wants the newest rows across every project cannot use it and scans the table to
|
||||
-- sort it. The new one is ordered the way the audit log reads, and the compound
|
||||
-- (created_at, id) matches the keyset the handler pages with — two rows written in
|
||||
-- the same second must not be able to hide each other across a page boundary.
|
||||
--
|
||||
-- The marker below is not here because the drop would cascade: nothing references
|
||||
-- `activity`, so it takes nothing with it. It is here for the check that comes
|
||||
-- with it. With foreign keys on, the copy is validated a row at a time against a
|
||||
-- schema that is half rebuilt, and the first bad row aborts with no idea how many
|
||||
-- others are like it. With them off, `PRAGMA foreign_key_check` runs once before
|
||||
-- the commit and names what is actually wrong — and this runs against a live
|
||||
-- database on somebody's next restart.
|
||||
--
|
||||
-- verdande:rebuild-tables
|
||||
|
||||
CREATE TABLE activity_rebuilt (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
task_id TEXT REFERENCES tasks (id) ON DELETE SET NULL,
|
||||
-- Nullable: the record of what was done outlives whoever did it.
|
||||
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||
event TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO activity_rebuilt (id, project_id, task_id, user_id, event, payload_json, created_at)
|
||||
SELECT id, project_id, task_id, user_id, event, payload_json, created_at FROM activity;
|
||||
|
||||
DROP TABLE activity;
|
||||
|
||||
ALTER TABLE activity_rebuilt RENAME TO activity;
|
||||
|
||||
CREATE INDEX idx_activity_project ON activity (project_id, created_at DESC);
|
||||
CREATE INDEX idx_activity_task ON activity (task_id) WHERE task_id IS NOT NULL;
|
||||
CREATE INDEX idx_activity_recent ON activity (created_at DESC, id DESC);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Which of the sidebar's fixed headings a person has folded away.
|
||||
--
|
||||
-- A project group already carries `collapsed` on its own row, and the reasoning
|
||||
-- there applies here too: folding a heading is a statement about the work, not
|
||||
-- about the screen. "I am not in Etiketter at the moment" is as true on the laptop
|
||||
-- as on the desktop, which is what separates this from the sidebar's *width* —
|
||||
-- that one is in localStorage, because a narrow laptop and a wide monitor deserve
|
||||
-- different answers and syncing it would make one of them wrong every time you
|
||||
-- moved.
|
||||
--
|
||||
-- A JSON array of keys rather than a column per heading. The set is the sidebar's
|
||||
-- to decide and it has already changed twice; a column each would mean a migration
|
||||
-- every time a heading is added, and a row full of booleans nobody reads for the
|
||||
-- ones that were removed. Unknown keys are ignored on the way out, so an old
|
||||
-- client's folded state survives a heading being renamed rather than resetting it.
|
||||
ALTER TABLE users ADD COLUMN sidebar_collapsed TEXT NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Settings that belong to the instance rather than to a person.
|
||||
--
|
||||
-- The first of them is the Gmail OAuth client. It was configuration only —
|
||||
-- `VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` — which on a Rune means editing the
|
||||
-- manifest and recreating the container to change a value you paste once and
|
||||
-- never touch again. The registration itself cannot be avoided (Google issues no
|
||||
-- Gmail access to an unregistered client, and `gmail.readonly` is a restricted
|
||||
-- scope, so a client id shipped inside a public image would be both extractable
|
||||
-- and unusable), but having to redeploy to enter it can.
|
||||
--
|
||||
-- Its own table rather than `user_settings`, for two reasons. An OAuth client is
|
||||
-- the instance's, not one person's — everybody connecting their mailbox uses the
|
||||
-- same registration. And `user_settings` has `user_id` as its whole primary key,
|
||||
-- so it can hold exactly one scope per person and its upsert rewrites the scope
|
||||
-- name on conflict; adding a second scope there would silently overwrite the AI
|
||||
-- provider's key.
|
||||
--
|
||||
-- JSON for the same reason templates are: written whole, read whole, and it would
|
||||
-- otherwise be a migration every time a setting is added.
|
||||
CREATE TABLE instance_settings (
|
||||
scope TEXT PRIMARY KEY,
|
||||
values_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
-- A group becomes somewhere you can go, rather than only a heading.
|
||||
--
|
||||
-- Until now a project group was a label over some rows in the sidebar: a name, a
|
||||
-- colour, and whether it was folded. That is enough to tidy a list and not enough
|
||||
-- to be the thing people actually mean by it — "Arbejde" is a body of work with
|
||||
-- context of its own: what it is, and the documents that belong to all of it
|
||||
-- rather than to any one project inside it.
|
||||
--
|
||||
-- Two changes. A description on the group, and attachments that can hang on one.
|
||||
--
|
||||
-- The attachments table has to be rebuilt for the second, because its CHECK spells
|
||||
-- out exactly which parent columns may be set, and SQLite cannot alter a CHECK.
|
||||
--
|
||||
-- Foreign keys go off for the length of it — see the marker below and the note in
|
||||
-- `applyMigration`. Not because the drop would cascade: nothing references
|
||||
-- `attachments`, so it takes nothing with it. It is for the check that comes with
|
||||
-- the marker. With foreign keys on, the copy is validated a row at a time against
|
||||
-- a half-rebuilt schema and the first bad row aborts with no idea how many others
|
||||
-- there are; with them off, `PRAGMA foreign_key_check` runs once before the commit
|
||||
-- and names what is actually wrong. This runs against a live database on somebody's
|
||||
-- next restart, and an attachment whose task was already gone would otherwise stop
|
||||
-- the server from starting with one row's worth of explanation.
|
||||
--
|
||||
-- The rule stays "exactly one parent": an attachment belongs to a task, a comment
|
||||
-- or a group, never two. An attachment with two parents is one that gets deleted
|
||||
-- once and stays visible in the other place.
|
||||
--
|
||||
-- verdande:rebuild-tables
|
||||
|
||||
ALTER TABLE project_groups ADD COLUMN description TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE attachments_rebuilt (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||||
comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE,
|
||||
group_id TEXT REFERENCES project_groups (id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
-- Exactly one parent, counted rather than spelled out as a chain of pairwise
|
||||
-- comparisons: with three columns that is three clauses, with four it is six,
|
||||
-- and the version that is wrong looks exactly like the version that is right.
|
||||
CHECK ((task_id IS NOT NULL) + (comment_id IS NOT NULL) + (group_id IS NOT NULL) = 1)
|
||||
);
|
||||
|
||||
INSERT INTO attachments_rebuilt
|
||||
(id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at)
|
||||
SELECT id, task_id, comment_id, filename, mime_type, size, path, uploaded_by, created_at
|
||||
FROM attachments;
|
||||
|
||||
DROP TABLE attachments;
|
||||
|
||||
ALTER TABLE attachments_rebuilt RENAME TO attachments;
|
||||
|
||||
CREATE INDEX idx_attachments_task ON attachments (task_id) WHERE task_id IS NOT NULL;
|
||||
CREATE INDEX idx_attachments_comment ON attachments (comment_id) WHERE comment_id IS NOT NULL;
|
||||
CREATE INDEX idx_attachments_group ON attachments (group_id) WHERE group_id IS NOT NULL;
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
@@ -346,3 +347,43 @@ func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values
|
||||
userID, scope, string(raw), time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// --- instance-wide settings -------------------------------------------------------------
|
||||
|
||||
// InstanceSettings reads one scope of the instance's own settings. A missing row
|
||||
// is an empty map, not an error.
|
||||
//
|
||||
// Separate from UserSettings because some configuration is the server's rather
|
||||
// than a person's — the Gmail OAuth client is one registration that everybody
|
||||
// connecting a mailbox goes through, not a preference each of them holds.
|
||||
func (db *DB) InstanceSettings(ctx context.Context, scope string) (map[string]any, error) {
|
||||
var raw string
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT values_json FROM instance_settings WHERE scope = ?`, scope).Scan(&raw)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (db *DB) SetInstanceSettings(ctx context.Context, scope string, values map[string]any) error {
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO instance_settings (scope, values_json, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (scope) DO UPDATE SET
|
||||
values_json = excluded.values_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
scope, string(raw), time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,19 +15,23 @@ import (
|
||||
// not. That is also why a shared project can never be in a group — see
|
||||
// SetProjectGroup.
|
||||
type ProjectGroup struct {
|
||||
ID string
|
||||
OwnerID string
|
||||
Name string
|
||||
Color string
|
||||
Collapsed bool
|
||||
SortOrder float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string
|
||||
OwnerID string
|
||||
Name string
|
||||
Color string
|
||||
// Description is what the group is, in the owner's words. A heading can carry
|
||||
// a name; a page can carry the sentence that says why these projects are the
|
||||
// same body of work.
|
||||
Description string
|
||||
Collapsed bool
|
||||
SortOrder float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGroup, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at
|
||||
`SELECT id, owner_id, name, color, description, collapsed, sort_order, created_at, updated_at
|
||||
FROM project_groups WHERE owner_id = ?
|
||||
ORDER BY sort_order, created_at`, userID)
|
||||
if err != nil {
|
||||
@@ -40,8 +44,8 @@ func (db *DB) ListProjectGroups(ctx context.Context, userID string) ([]ProjectGr
|
||||
var g ProjectGroup
|
||||
var collapsed int
|
||||
var created, updated int64
|
||||
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder,
|
||||
&created, &updated); err != nil {
|
||||
if err := rows.Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &g.Description, &collapsed,
|
||||
&g.SortOrder, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Collapsed = collapsed == 1
|
||||
@@ -60,9 +64,10 @@ func (db *DB) GetProjectGroup(ctx context.Context, groupID, userID string) (*Pro
|
||||
var collapsed int
|
||||
var created, updated int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, owner_id, name, color, collapsed, sort_order, created_at, updated_at
|
||||
`SELECT id, owner_id, name, color, description, collapsed, sort_order, created_at, updated_at
|
||||
FROM project_groups WHERE id = ? AND owner_id = ?`, groupID, userID).
|
||||
Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &collapsed, &g.SortOrder, &created, &updated)
|
||||
Scan(&g.ID, &g.OwnerID, &g.Name, &g.Color, &g.Description, &collapsed, &g.SortOrder,
|
||||
&created, &updated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -101,18 +106,20 @@ func (db *DB) CreateProjectGroup(ctx context.Context, g *ProjectGroup) error {
|
||||
}
|
||||
|
||||
type ProjectGroupUpdate struct {
|
||||
Name *string
|
||||
Color *string
|
||||
Collapsed *bool
|
||||
SortOrder *float64
|
||||
Name *string
|
||||
Color *string
|
||||
Description *string
|
||||
Collapsed *bool
|
||||
SortOrder *float64
|
||||
}
|
||||
|
||||
func (db *DB) UpdateProjectGroup(ctx context.Context, groupID, userID string, u ProjectGroupUpdate) error {
|
||||
set, args := buildUpdate(map[string]any{
|
||||
"name": u.Name,
|
||||
"color": u.Color,
|
||||
"collapsed": u.Collapsed,
|
||||
"sort_order": u.SortOrder,
|
||||
"name": u.Name,
|
||||
"color": u.Color,
|
||||
"description": u.Description,
|
||||
"collapsed": u.Collapsed,
|
||||
"sort_order": u.SortOrder,
|
||||
})
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
@@ -209,3 +216,37 @@ func (db *DB) SetProjectGroup(ctx context.Context, projectID, groupID, userID st
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenTaskCounts is how much is left in each project of a group.
|
||||
//
|
||||
// One query rather than one per project: the group's page draws a list, and a
|
||||
// count per row would be a query per row.
|
||||
//
|
||||
// Open, not total. "12" beside a project you finished last year is a number that
|
||||
// means nothing; the question a list of projects answers is what is still on it.
|
||||
// Sub-tasks are excluded for the same reason the project view nests them — they
|
||||
// are part of their parent, not another thing to do.
|
||||
func (db *DB) OpenTaskCounts(ctx context.Context, groupID, userID string) (map[string]int, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT p.id, count(t.id)
|
||||
FROM projects p
|
||||
LEFT JOIN tasks t ON t.project_id = p.id
|
||||
AND t.deleted_at IS NULL AND t.completed_at IS NULL AND t.parent_id IS NULL
|
||||
WHERE p.group_id = ? AND p.owner_id = ? AND p.deleted_at IS NULL
|
||||
GROUP BY p.id`, groupID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
counts := map[string]int{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var n int
|
||||
if err := rows.Scan(&id, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts[id] = n
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
@@ -630,3 +630,36 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SectionByName resolves a "/name" from quick add within one project.
|
||||
//
|
||||
// Case-insensitive, and scoped to the project the task is landing in — a section
|
||||
// belongs to exactly one project, so the name cannot be looked up before that is
|
||||
// decided. Two projects may both have a "Produktion" and they are different
|
||||
// sections; asking without the project would be asking the wrong question.
|
||||
func (db *DB) SectionByName(ctx context.Context, projectID, name string) (string, error) {
|
||||
var id string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id FROM sections
|
||||
WHERE project_id = ? AND lower(name) = lower(?) AND deleted_at IS NULL
|
||||
ORDER BY sort_order
|
||||
LIMIT 1`, projectID, name).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// SectionInProject reports whether a section id belongs to the given project.
|
||||
//
|
||||
// Quick add takes a section id from the client — the field at the foot of a
|
||||
// section passes the one it sits in — and a "#project" in the same line can move
|
||||
// the task somewhere else entirely. Without this the id would follow it, and the
|
||||
// task would land in a section belonging to a project it is not in.
|
||||
func (db *DB) SectionInProject(ctx context.Context, sectionID, projectID string) (bool, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM sections WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
|
||||
sectionID, projectID).Scan(&n)
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
@@ -25,15 +25,18 @@ type DB struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// Open prepares the database at path and brings the schema up to date.
|
||||
// dsn is the connection string.
|
||||
//
|
||||
// SQLite is a single file, but it is being driven by an HTTP server with concurrent
|
||||
// readers and a WebSocket fan-out, so the pragmas below matter more than usual:
|
||||
// WAL lets readers run while a write is in flight, busy_timeout turns the "database
|
||||
// is locked" race into a short wait, and foreign_keys is off by default in SQLite
|
||||
// and has to be asked for on every connection.
|
||||
func Open(path string) (*DB, error) {
|
||||
dsn := "file:" + filepath.ToSlash(path) + "?" + strings.Join([]string{
|
||||
//
|
||||
// Its own function so the migration tests can open a database with exactly the
|
||||
// pragmas production runs with.
|
||||
func dsn(path string) string {
|
||||
return "file:" + filepath.ToSlash(path) + "?" + strings.Join([]string{
|
||||
"_pragma=journal_mode(WAL)",
|
||||
"_pragma=busy_timeout(5000)",
|
||||
"_pragma=foreign_keys(ON)",
|
||||
@@ -56,8 +59,11 @@ func Open(path string) (*DB, error) {
|
||||
// spilling them into the data volume.
|
||||
"_pragma=temp_store(MEMORY)",
|
||||
}, "&")
|
||||
}
|
||||
|
||||
sqlDB, err := sql.Open("sqlite", dsn)
|
||||
// Open prepares the database at path and brings the schema up to date.
|
||||
func Open(path string) (*DB, error) {
|
||||
sqlDB, err := sql.Open("sqlite", dsn(path))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
@@ -91,11 +97,36 @@ func Open(path string) (*DB, error) {
|
||||
|
||||
func (db *DB) Path() string { return db.path }
|
||||
|
||||
// rebuildMarker opts a migration out of foreign key enforcement for the length of
|
||||
// its transaction.
|
||||
//
|
||||
// SQLite cannot alter a column's foreign key action, so changing one means
|
||||
// rebuilding the table: copy it, drop the original, rename the copy back. With
|
||||
// foreign keys on, the drop is a cascading delete — every row in every table that
|
||||
// references `tasks` would go with it, which is the opposite of what a migration
|
||||
// that exists to *stop* losing rows should do. `PRAGMA foreign_keys` is a no-op
|
||||
// inside a transaction, so it has to be set on the connection before BEGIN; that
|
||||
// is why migrations run on a connection of their own rather than on the pool.
|
||||
//
|
||||
// `PRAGMA foreign_key_check` runs before the commit, so a rebuild that leaves a
|
||||
// dangling reference fails the migration instead of writing it.
|
||||
const rebuildMarker = "-- verdande:rebuild-tables"
|
||||
|
||||
// migrate applies every embedded migration that has not run yet, in filename order,
|
||||
// each inside its own transaction. A migration that fails leaves the schema at the
|
||||
// last version that succeeded rather than half-applied.
|
||||
//
|
||||
// Everything here runs on one connection: a migration marked with rebuildMarker
|
||||
// needs a pragma to hold across its transaction, and a pragma only applies to the
|
||||
// connection it was issued on.
|
||||
func (db *DB) migrate(ctx context.Context) error {
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
@@ -104,7 +135,7 @@ func (db *DB) migrate(ctx context.Context) error {
|
||||
}
|
||||
|
||||
applied := map[string]bool{}
|
||||
rows, err := db.QueryContext(ctx, `SELECT name FROM schema_migrations`)
|
||||
rows, err := conn.QueryContext(ctx, `SELECT name FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read schema_migrations: %w", err)
|
||||
}
|
||||
@@ -136,27 +167,69 @@ func (db *DB) migrate(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
if err := db.applyMigration(ctx, conn, name, string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) applyMigration(ctx context.Context, conn *sql.Conn, name, body string) error {
|
||||
if strings.Contains(body, rebuildMarker) {
|
||||
if _, err := conn.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return fmt.Errorf("migration %s: disable foreign keys: %w", name, err)
|
||||
}
|
||||
// Back on whatever happens below: this connection goes back to the pool.
|
||||
defer conn.ExecContext(ctx, `PRAGMA foreign_keys = ON`)
|
||||
}
|
||||
|
||||
tx, err := conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, body); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %s: %w", name, err)
|
||||
}
|
||||
if strings.Contains(body, rebuildMarker) {
|
||||
if err := foreignKeyCheck(ctx, tx); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("record migration %s: %w", name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// foreignKeyCheck reports the first row that references something that is not
|
||||
// there. It names the table and the rowid, because a rebuild that drops rows on the
|
||||
// floor is otherwise indistinguishable from one that worked.
|
||||
func foreignKeyCheck(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `PRAGMA foreign_key_check`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("foreign key check: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if rows.Next() {
|
||||
var table, parent string
|
||||
var rowid sql.NullInt64
|
||||
var fkid int
|
||||
if err := rows.Scan(&table, &rowid, &parent, &fkid); err != nil {
|
||||
return fmt.Errorf("foreign key check: %w", err)
|
||||
}
|
||||
return fmt.Errorf("foreign key check: %s row %d references a missing %s",
|
||||
table, rowid.Int64, parent)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// Tx runs fn inside a transaction, rolling back on error or panic.
|
||||
func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
|
||||
@@ -2,9 +2,13 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// openTest gives each test its own database file. In-memory SQLite would be faster,
|
||||
@@ -410,3 +414,213 @@ func ftsCount(t *testing.T, db *DB, query string) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// The rebuild in 0008 is the one migration that does not just add: it copies the
|
||||
// tasks table, drops the original and renames the copy back. Every test above this
|
||||
// one starts from an empty database, where a rebuild that loses rows looks exactly
|
||||
// like a rebuild that works — so this one carries data across it.
|
||||
//
|
||||
// What can go wrong, in the order it would: the drop cascades and takes the child
|
||||
// rows with it; the copy misses a column; the FTS index is left keyed on rowids the
|
||||
// rebuilt table no longer hands out; and finally the thing the migration is for,
|
||||
// which is that deleting an account stops deleting other people's work.
|
||||
func TestTheTasksRebuildCarriesTheDataAcross(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "upgrade.db")
|
||||
|
||||
// A database at 0007: the schema as it shipped in v0.10.2.
|
||||
sqlDB, err := sql.Open("sqlite", dsn(path))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
if _, err := sqlDB.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := fs.Glob(migrationFS, "migrations/*.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(entries)
|
||||
for _, entry := range entries {
|
||||
name := filepath.Base(entry)
|
||||
if name >= "0008" {
|
||||
break
|
||||
}
|
||||
body, err := migrationFS.ReadFile(entry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := sqlDB.ExecContext(ctx, string(body)); err != nil {
|
||||
t.Fatalf("apply %s: %v", name, err)
|
||||
}
|
||||
if _, err := sqlDB.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, unixepoch())`, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two people, one project each, and a task in each project written by the
|
||||
// other — which is the shape the migration exists for. Plus a label, a comment
|
||||
// and a sub-task, so the child tables have something to lose.
|
||||
now := time.Now().Unix()
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO users (id, email, name, password_hash, avatar_color, is_admin, created_at, updated_at)
|
||||
VALUES ('u1', 'en@example.dk', 'En', 'x', 'graphite', 1, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
|
||||
('u2', 'to@example.dk', 'To', 'x', 'graphite', 0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||
`INSERT INTO projects (id, owner_id, name, color, created_at, updated_at)
|
||||
VALUES ('p1', 'u1', 'Ens', 'graphite', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
|
||||
('p2', 'u2', 'Tos', 'graphite', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||
`INSERT INTO tasks (id, project_id, content, description, created_by, priority, sort_order, created_at, updated_at)
|
||||
VALUES ('t1', 'p1', 'grøn hæk', 'i ens eget', 'u1', 2, 1.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `),
|
||||
('t2', 'p2', 'skrevet af en anden', '', 'u1', 4, 2.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||
`INSERT INTO tasks (id, project_id, parent_id, content, created_by, sort_order, created_at, updated_at)
|
||||
VALUES ('t3', 'p1', 't1', 'undertask', 'u1', 3.0, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||
`INSERT INTO labels (id, user_id, name, created_at) VALUES ('l1', 'u1', 'have', ` + fmt.Sprint(now) + `)`,
|
||||
`INSERT INTO task_labels (task_id, label_id) VALUES ('t1', 'l1')`,
|
||||
`INSERT INTO comments (id, task_id, user_id, body, created_at, updated_at)
|
||||
VALUES ('c1', 't1', 'u1', 'en kommentar', ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||
// 0009 rebuilds activity and 0012 rebuilds attachments, so both need rows
|
||||
// here: a rebuild that drops them looks exactly like one that works.
|
||||
`INSERT INTO activity (id, project_id, task_id, user_id, event, payload_json, created_at)
|
||||
VALUES ('a1', 'p1', 't1', 'u1', 'task.created', '{}', ` + fmt.Sprint(now) + `),
|
||||
('a2', 'p2', NULL, 'u1', 'project.created', '{}', ` + fmt.Sprint(now) + `)`,
|
||||
`INSERT INTO attachments (id, task_id, filename, mime_type, size, path, uploaded_by, created_at)
|
||||
VALUES ('f1', 't1', 'tegning.pdf', 'application/pdf', 1024, 'ab/cd/abcd', 'u1', ` + fmt.Sprint(now) + `)`,
|
||||
`INSERT INTO project_groups (id, owner_id, name, color, collapsed, sort_order, created_at, updated_at)
|
||||
VALUES ('g1', 'u1', 'Arbejde', 'graphite', 0, 1024, ` + fmt.Sprint(now) + `, ` + fmt.Sprint(now) + `)`,
|
||||
} {
|
||||
if _, err := sqlDB.ExecContext(ctx, stmt); err != nil {
|
||||
t.Fatalf("seed: %v\n%s", err, stmt)
|
||||
}
|
||||
}
|
||||
sqlDB.Close()
|
||||
|
||||
// Now the upgrade, through the real runner.
|
||||
db, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var tasks, labels, comments int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM tasks`).Scan(&tasks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT count(*) FROM task_labels`).Scan(&labels); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT count(*) FROM comments`).Scan(&comments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tasks != 3 || labels != 1 || comments != 1 {
|
||||
t.Fatalf("after the rebuild: %d tasks, %d labels, %d comments — want 3, 1, 1. "+
|
||||
"A drop that cascaded would look exactly like this", tasks, labels, comments)
|
||||
}
|
||||
|
||||
// 0009 and 0012 rebuild activity and attachments by the same machinery. They
|
||||
// run against a live database on the next restart, and the thing that goes
|
||||
// wrong in a rebuild is rows quietly not arriving.
|
||||
var activity, attachments, groups int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM activity`).Scan(&activity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT count(*) FROM attachments`).Scan(&attachments); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT count(*) FROM project_groups`).Scan(&groups); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if activity != 2 || attachments != 1 || groups != 1 {
|
||||
t.Fatalf("after 0009 and 0012: %d activity rows, %d attachments, %d groups — want 2, 1, 1",
|
||||
activity, attachments, groups)
|
||||
}
|
||||
|
||||
// The attachment kept its parent and its bytes. A rebuild that lost the task_id
|
||||
// leaves a row nothing can reach, which is worse than losing it outright.
|
||||
var fileParent, filename string
|
||||
if err := db.QueryRow(
|
||||
`SELECT task_id, filename FROM attachments WHERE id = 'f1'`).Scan(&fileParent, &filename); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fileParent != "t1" || filename != "tegning.pdf" {
|
||||
t.Errorf("the attachment came across as %q on %q", filename, fileParent)
|
||||
}
|
||||
|
||||
// And the new columns arrived with their defaults rather than as NULL, which is
|
||||
// what an ALTER that forgot NOT NULL DEFAULT would give.
|
||||
var groupAbout, sidebar string
|
||||
if err := db.QueryRow(`SELECT description FROM project_groups WHERE id = 'g1'`).Scan(&groupAbout); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT sidebar_collapsed FROM users WHERE id = 'u1'`).Scan(&sidebar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if groupAbout != "" || sidebar != "[]" {
|
||||
t.Errorf("new columns on existing rows: description %q, sidebar_collapsed %q", groupAbout, sidebar)
|
||||
}
|
||||
|
||||
// Every column came across, including the generated one and the sub-task's
|
||||
// self-reference.
|
||||
var content, description, parent string
|
||||
var priority int
|
||||
if err := db.QueryRow(
|
||||
`SELECT content, description, priority FROM tasks WHERE id = 't1'`).
|
||||
Scan(&content, &description, &priority); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != "grøn hæk" || description != "i ens eget" || priority != 2 {
|
||||
t.Errorf("t1 came across as %q/%q/%d", content, description, priority)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT parent_id FROM tasks WHERE id = 't3'`).Scan(&parent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parent != "t1" {
|
||||
t.Errorf("the sub-task lost its parent: %q", parent)
|
||||
}
|
||||
|
||||
// The FTS index was rebuilt against the new rowids. Searching the fold column
|
||||
// is the sharpest check: it is generated, so a copy that skipped it would still
|
||||
// have the row and not the index entry.
|
||||
for _, q := range []string{"grøn", "gron", "haek"} {
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM tasks_fts WHERE tasks_fts MATCH ?`, q).Scan(&n); err != nil {
|
||||
t.Fatalf("search %q: %v", q, err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("search for %q found %d rows, want 1 — the index is keyed on rowids "+
|
||||
"the rebuilt table no longer hands out", q, n)
|
||||
}
|
||||
}
|
||||
|
||||
// And the point of the whole thing.
|
||||
if err := db.DeleteUser(ctx, "u1"); err != nil {
|
||||
t.Fatalf("delete user: %v", err)
|
||||
}
|
||||
var survived int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE id = 't2'`).Scan(&survived); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if survived != 1 {
|
||||
t.Fatal("a task written in somebody else's project went with the account")
|
||||
}
|
||||
var author sql.NullString
|
||||
if err := db.QueryRow(`SELECT created_by FROM tasks WHERE id = 't2'`).Scan(&author); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if author.Valid {
|
||||
t.Errorf("created_by = %q, want null", author.String)
|
||||
}
|
||||
// Their own project still goes, and everything in it with it.
|
||||
var own int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM tasks WHERE project_id = 'p1'`).Scan(&own); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if own != 0 {
|
||||
t.Errorf("%d tasks survived in the deleted account's own project", own)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,23 @@ type Task struct {
|
||||
AssigneeID string
|
||||
CompletedAt *time.Time
|
||||
CompletedBy string
|
||||
CreatedBy string
|
||||
SortOrder float64
|
||||
Labels []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// CreatedBy is empty when the author's account has been deleted: `created_by`
|
||||
// is ON DELETE SET NULL so the task survives the person who wrote it.
|
||||
CreatedBy string
|
||||
SortOrder float64
|
||||
// SubtaskCount and SubtaskDone are what a row can say without being opened:
|
||||
// that there is something underneath it, and how much of it is left. Counted
|
||||
// for every task in a list, in the same query — a hundred extra lookups to draw
|
||||
// a badge is not a badge worth having.
|
||||
SubtaskCount int
|
||||
SubtaskDone int
|
||||
// AttachmentCount is the same idea for files. A task with a drawing on it looks
|
||||
// exactly like one without, and the whole reason to attach something is that
|
||||
// somebody should find it.
|
||||
AttachmentCount int
|
||||
Labels []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (t *Task) Completed() bool { return t.CompletedAt != nil }
|
||||
@@ -173,11 +185,11 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at,` + taskCounts + `
|
||||
FROM tasks t
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at`
|
||||
ORDER BY ` + order(f)
|
||||
|
||||
if f.Limit > 0 {
|
||||
query += ` LIMIT ?`
|
||||
@@ -264,11 +276,11 @@ func (db *DB) GetTask(ctx context.Context, taskID, userID string) (*Task, error)
|
||||
return nil, err
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, project_id, section_id, parent_id, content, description,
|
||||
priority, due_date, due_datetime, due_timezone, duration_min,
|
||||
recurrence_rule, assignee_id, completed_at, completed_by,
|
||||
created_by, sort_order, created_at, updated_at
|
||||
FROM tasks WHERE id = ? AND deleted_at IS NULL`, taskID)
|
||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||
t.priority, t.due_date, t.due_datetime, t.due_timezone, t.duration_min,
|
||||
t.recurrence_rule, t.assignee_id, t.completed_at, t.completed_by,
|
||||
t.created_by, t.sort_order, t.created_at, t.updated_at,`+taskCounts+`
|
||||
FROM tasks t WHERE t.id = ? AND t.deleted_at IS NULL`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -317,9 +329,42 @@ func (db *DB) taskLabels(ctx context.Context, taskID string) ([]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// taskCounts are what a row can say about itself without being opened.
|
||||
//
|
||||
// Correlated subqueries rather than a second round of lookups: a list of fifty
|
||||
// tasks would otherwise be a hundred more queries, and the whole point is that a
|
||||
// row shows this without anybody asking for it. `idx_tasks_parent` covers the
|
||||
// first two; the attachment count is a small table with a task_id on it.
|
||||
//
|
||||
// Sub-tasks are counted whether they are open or closed, and the finished ones
|
||||
// again separately, because "3" and "1 of 3" are different sentences and only the
|
||||
// second one says whether there is anything left to do.
|
||||
const taskCounts = `
|
||||
(SELECT count(*) FROM tasks c
|
||||
WHERE c.parent_id = t.id AND c.deleted_at IS NULL),
|
||||
(SELECT count(*) FROM tasks c
|
||||
WHERE c.parent_id = t.id AND c.deleted_at IS NULL AND c.completed_at IS NOT NULL),
|
||||
(SELECT count(*) FROM attachments a WHERE a.task_id = t.id)`
|
||||
|
||||
// order is how a list of tasks is sorted, which depends on what the list is for.
|
||||
//
|
||||
// A plan is read by when it is due and how urgent it is. A record of what has been
|
||||
// finished is read backwards from now — "what did I just do" and "what did I close
|
||||
// by mistake" are both questions about the most recent thing, and neither is
|
||||
// answered by a due date the task no longer has.
|
||||
func order(f TaskFilter) string {
|
||||
if f.CompletedOnly {
|
||||
return "t.completed_at DESC, t.id DESC"
|
||||
}
|
||||
return "t.due_date IS NULL, t.due_date, t.priority, t.sort_order, t.created_at"
|
||||
}
|
||||
|
||||
func scanTask(rows *sql.Rows) (Task, error) {
|
||||
var t Task
|
||||
var sectionID, parentID, dueDate, dueTZ, recurrence, assignee, completedBy sql.NullString
|
||||
// createdBy is null on a task whose author's account has been deleted. The work
|
||||
// stays; it loses its author.
|
||||
var createdBy sql.NullString
|
||||
var dueDatetime, completedAt sql.NullInt64
|
||||
var duration sql.NullInt64
|
||||
var created, updated int64
|
||||
@@ -327,13 +372,15 @@ func scanTask(rows *sql.Rows) (Task, error) {
|
||||
err := rows.Scan(&t.ID, &t.ProjectID, §ionID, &parentID, &t.Content, &t.Description,
|
||||
&t.Priority, &dueDate, &dueDatetime, &dueTZ, &duration,
|
||||
&recurrence, &assignee, &completedAt, &completedBy,
|
||||
&t.CreatedBy, &t.SortOrder, &created, &updated)
|
||||
&createdBy, &t.SortOrder, &created, &updated,
|
||||
&t.SubtaskCount, &t.SubtaskDone, &t.AttachmentCount)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
|
||||
t.SectionID = sectionID.String
|
||||
t.ParentID = parentID.String
|
||||
t.CreatedBy = createdBy.String
|
||||
t.DueDate = dueDate.String
|
||||
t.DueTimezone = dueTZ.String
|
||||
t.RecurrenceRule = recurrence.String
|
||||
@@ -384,7 +431,7 @@ func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) erro
|
||||
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
|
||||
t.Content, t.Description, t.Priority, nullString(t.DueDate),
|
||||
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
|
||||
nullString(t.RecurrenceRule), nullString(t.AssigneeID), t.CreatedBy,
|
||||
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
|
||||
t.SortOrder, now.Unix(), now.Unix())
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -28,8 +29,15 @@ type User struct {
|
||||
Timezone string
|
||||
Locale string
|
||||
IsAdmin bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// SidebarCollapsed is the sidebar headings this person has folded away, by
|
||||
// key. On the account rather than in localStorage, for the same reason a
|
||||
// project group carries `collapsed` on its row: folding a heading is a
|
||||
// statement about the work — "I am not in Etiketter at the moment" — and that
|
||||
// is as true on the laptop as on the desktop. The sidebar's *width* is the
|
||||
// opposite case and is stored per browser.
|
||||
SidebarCollapsed []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
||||
@@ -96,7 +104,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
|
||||
}
|
||||
|
||||
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
||||
avatar_color, timezone, locale, is_admin, created_at, updated_at`
|
||||
avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at`
|
||||
|
||||
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
||||
var u User
|
||||
@@ -104,9 +112,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
var totpEnabled, isAdmin int
|
||||
var created, updated int64
|
||||
|
||||
var collapsed string
|
||||
|
||||
err := db.QueryRowContext(ctx, query, args...).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &created, &updated)
|
||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -117,11 +127,41 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
u.TOTPSecret = secret.String
|
||||
u.TOTPEnabled = totpEnabled == 1
|
||||
u.IsAdmin = isAdmin == 1
|
||||
// A malformed value is read as "nothing folded" rather than as an error: the
|
||||
// worst case is a sidebar that opens fully, which is the state it shipped in.
|
||||
u.SidebarCollapsed = []string{}
|
||||
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
|
||||
u.CreatedAt = time.Unix(created, 0).UTC()
|
||||
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// SetSidebarCollapsed records which sidebar headings are folded.
|
||||
//
|
||||
// Its own method rather than a field on UpdateProfile: that one backs a form with
|
||||
// a save button and validates three fields together, and this is a toggle that
|
||||
// writes on every click. Sharing the path would mean a fold sending a name and a
|
||||
// timezone it was never asked to change.
|
||||
func (db *DB) SetSidebarCollapsed(ctx context.Context, userID string, sections []string) error {
|
||||
if sections == nil {
|
||||
sections = []string{}
|
||||
}
|
||||
raw, err := json.Marshal(sections)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET sidebar_collapsed = ?, updated_at = ? WHERE id = ?`,
|
||||
string(raw), time.Now().Unix(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Person is the public half of a user: what somebody else is allowed to see when
|
||||
// their name has to appear next to a task. No address, no hash, no timezone.
|
||||
type Person struct {
|
||||
@@ -175,12 +215,17 @@ type UserSummary struct {
|
||||
// ProjectCount excludes the Inbox: every account has one, so counting it would
|
||||
// make "1 project" mean "nothing".
|
||||
ProjectCount int
|
||||
// TaskCount is everything a delete would destroy, which is not the same as
|
||||
// "their tasks": it is the tasks in projects they own *plus* every task they
|
||||
// wrote anywhere, because tasks.created_by cascades. A number that counted
|
||||
// only the first would understate the damage in exactly the case that matters
|
||||
// — a colleague leaving a shared project.
|
||||
// TaskCount is everything a delete would destroy: the tasks in the projects
|
||||
// they own, which go with those projects. Tasks they merely *wrote* in
|
||||
// somebody else's project are not counted, because they no longer go —
|
||||
// `created_by` is ON DELETE SET NULL, so that work stays and loses its author.
|
||||
TaskCount int
|
||||
// AuthoredElsewhere is what a delete leaves behind unattributed: tasks they
|
||||
// wrote in projects other people own. Separate from TaskCount because it is a
|
||||
// different sentence — "this much disappears" and "this much stays without a
|
||||
// name on it" — and running them together was what made the old number sound
|
||||
// like a threat it no longer is.
|
||||
AuthoredElsewhere int
|
||||
}
|
||||
|
||||
// ListUsers returns every account on the instance. Administrators only — this is
|
||||
@@ -192,8 +237,9 @@ func (db *DB) ListUsers(ctx context.Context) ([]UserSummary, error) {
|
||||
(SELECT count(*) FROM projects p
|
||||
WHERE p.owner_id = u.id AND p.is_inbox = 0 AND p.deleted_at IS NULL),
|
||||
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
|
||||
WHERE t.deleted_at IS NULL
|
||||
AND (p.owner_id = u.id OR t.created_by = u.id))
|
||||
WHERE t.deleted_at IS NULL AND p.owner_id = u.id),
|
||||
(SELECT count(*) FROM tasks t JOIN projects p ON p.id = t.project_id
|
||||
WHERE t.deleted_at IS NULL AND t.created_by = u.id AND p.owner_id <> u.id)
|
||||
FROM users u
|
||||
ORDER BY u.is_admin DESC, u.name`)
|
||||
if err != nil {
|
||||
@@ -207,7 +253,7 @@ func (db *DB) ListUsers(ctx context.Context) ([]UserSummary, error) {
|
||||
var isAdmin int
|
||||
var created, lastSeen int64
|
||||
if err := rows.Scan(&s.ID, &s.Email, &s.Name, &s.AvatarColor, &isAdmin, &created,
|
||||
&lastSeen, &s.ProjectCount, &s.TaskCount); err != nil {
|
||||
&lastSeen, &s.ProjectCount, &s.TaskCount, &s.AuthoredElsewhere); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.IsAdmin = isAdmin == 1
|
||||
@@ -249,20 +295,18 @@ func (db *DB) SetUserAdmin(ctx context.Context, userID string, admin bool) error
|
||||
// there is no way back — which is why the handler sends the counts to the
|
||||
// interface first.
|
||||
//
|
||||
// What goes is wider than it looks. `projects.owner_id` cascades, so their
|
||||
// projects go and every task in them goes too. But `tasks.created_by` cascades as
|
||||
// well, so **every task they ever wrote goes, including in projects somebody else
|
||||
// owns**. Retiring a colleague's account therefore removes their contributions
|
||||
// from shared projects rather than leaving them behind unattributed.
|
||||
// What goes: `projects.owner_id` cascades, so their projects go and every task in
|
||||
// them goes too. That is the whole of it, and the handler sends the count to the
|
||||
// interface first.
|
||||
//
|
||||
// That is the schema as it stands rather than a decision made here. Changing it —
|
||||
// `created_by` as ON DELETE SET NULL, so the work stays and loses its author —
|
||||
// means rebuilding the tasks table, which carries the FTS triggers with it. Worth
|
||||
// doing; not worth doing quietly as part of adding a screen.
|
||||
// What stays: everything they wrote in somebody else's project. `tasks.created_by`
|
||||
// used to cascade as well, which meant retiring a colleague's account removed
|
||||
// their contributions from shared work — see migration 0008, which rebuilt the
|
||||
// table to make it ON DELETE SET NULL. The task survives and loses its author,
|
||||
// which is the honest record of what happened.
|
||||
//
|
||||
// What survives: tasks merely *assigned* to them, which are unassigned by
|
||||
// `assignee_id ON DELETE SET NULL`, and completions, by the same rule on
|
||||
// `completed_by`.
|
||||
// Tasks merely *assigned* to them are unassigned by `assignee_id ON DELETE SET
|
||||
// NULL`, and completions by the same rule on `completed_by`.
|
||||
func (db *DB) DeleteUser(ctx context.Context, userID string) error {
|
||||
res, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -466,6 +466,46 @@
|
||||
/>
|
||||
<figcaption>The light theme.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/week.png"
|
||||
alt="Upcoming as a single week: seven columns of days, each holding the tasks due that day."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>A week at a time — so a task can be dragged across a month boundary.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/detail.png"
|
||||
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>A task, opened.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/themes.png"
|
||||
alt="The settings page showing the five themes as swatches."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>Five themes, all of them measured for contrast.</figcaption>
|
||||
</figure>
|
||||
<figure class="shot">
|
||||
<img
|
||||
src="screenshots/history.png"
|
||||
alt="The instance-wide history page: who did what, in which project, filtered by person and event."
|
||||
width="1400"
|
||||
height="900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>What has been done on the server, across every project.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 163 KiB |
@@ -19,6 +19,25 @@ import { USER } from './user.js';
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*/
|
||||
/**
|
||||
* Opens the project header's overflow menu and clicks one of its items.
|
||||
*
|
||||
* The header used to carry eight controls in one row — title, colour, show-done,
|
||||
* three view buttons, share, history, delete — and a project name is not a short
|
||||
* word, so the heading was what gave way. Everything but the view switcher moved
|
||||
* behind one button; this is how a test reaches them.
|
||||
*/
|
||||
async function projectAction(page, name) {
|
||||
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
||||
// `exact` only means anything for a string. The share item reads "Delt · 2" once
|
||||
// somebody is in the project, so callers pass a regex for that one.
|
||||
const item =
|
||||
name instanceof RegExp
|
||||
? page.getByRole('menuitem', { name })
|
||||
: page.getByRole('menuitem', { name, exact: true });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
@@ -153,9 +172,11 @@ test('hver fane under indstillinger renderer', async ({ page }) => {
|
||||
'Integrationer',
|
||||
'AI',
|
||||
'API-tokens',
|
||||
// Only an administrator sees this one, and the account these tests run as
|
||||
// Only an administrator sees these two, and the account these tests run as
|
||||
// is the first account, which is one.
|
||||
'Brugere',
|
||||
'Historik',
|
||||
'Fejl',
|
||||
'Data og skabeloner'
|
||||
]) {
|
||||
await tabs.getByRole('link', { name: label, exact: true }).click();
|
||||
@@ -183,6 +204,12 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
|
||||
expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy();
|
||||
}
|
||||
|
||||
// Safari never reads the manifest's icons; it looks for this by name, and
|
||||
// without it an installed PWA on iOS gets a screenshot of the page as its
|
||||
// home-screen icon.
|
||||
const appleIcon = await request.get('/apple-touch-icon.png');
|
||||
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
||||
|
||||
// The service worker is what Web Push needs; without it a subscription cannot
|
||||
// be created at all.
|
||||
const worker = await request.get('/sw.js');
|
||||
@@ -216,7 +243,7 @@ test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: 'Slet projektet' }).click();
|
||||
await projectAction(page, 'Slet projektet');
|
||||
|
||||
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
|
||||
|
||||
@@ -256,10 +283,15 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
|
||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||
|
||||
// `exact`, because the heading's colour dot is a button too and its label is
|
||||
// "Farve på Arbejde" — and Playwright matches an accessible name by substring
|
||||
// unless told otherwise.
|
||||
const heading = sidebar.getByRole('button', { name: 'Arbejde', exact: true });
|
||||
// The name is a link to the group's own page; the chevron beside it is a
|
||||
// separate button that folds. Two intentions, two targets — the single one they
|
||||
// used to share could only ever serve the smaller.
|
||||
//
|
||||
// `exact` on the link, because a group's name is a user-chosen word that can
|
||||
// turn up inside another control's name, and Playwright matches an accessible
|
||||
// name by substring unless told otherwise.
|
||||
const heading = sidebar.getByRole('link', { name: 'Arbejde', exact: true });
|
||||
const fold = sidebar.getByRole('button', { name: /^Fold Arbejde/ });
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
||||
|
||||
@@ -270,7 +302,9 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
|
||||
// Both buttons still worked, so every assertion in this file passed.
|
||||
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
|
||||
[...el.querySelectorAll('button')].map((b) => {
|
||||
// Links as well as buttons: the group's name is a link to its page now, and
|
||||
// a measurement that skipped it would miss the widest thing in the row.
|
||||
[...el.querySelectorAll('button, a')].map((b) => {
|
||||
const r = b.getBoundingClientRect();
|
||||
return { text: b.textContent.trim(), left: r.left, right: r.right };
|
||||
})
|
||||
@@ -295,25 +329,43 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
|
||||
// Folded, reloaded, still folded. localStorage would have passed the first
|
||||
// half of that and failed the second on another machine.
|
||||
await heading.click();
|
||||
await expect(heading).toHaveAttribute('aria-expanded', 'false');
|
||||
await fold.click();
|
||||
await expect(fold).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.reload();
|
||||
await expect(sidebar.getByRole('button', { name: 'Arbejde', exact: true })).toHaveAttribute(
|
||||
await expect(sidebar.getByRole('button', { name: /^Fold Arbejde/ })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
);
|
||||
await sidebar.getByRole('button', { name: 'Arbejde', exact: true }).click();
|
||||
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
||||
|
||||
// Colour, and indentation. The dot is the control as well as the preview, so
|
||||
// the thing you press is the thing you are about to change.
|
||||
await sidebar.getByRole('button', { name: 'Farve på Arbejde' }).click();
|
||||
// Colour, which is chosen while renaming rather than from the row. Both are
|
||||
// edits to the same thing, and a heading carrying four controls crowded the
|
||||
// name it exists to show.
|
||||
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
|
||||
await sidebar.getByRole('button', { name: 'Petrol' }).click();
|
||||
// Still open: picking a colour must not close the form under the pointer, which
|
||||
// is what a plain blur-to-cancel does in the two browsers that do not focus a
|
||||
// button on click.
|
||||
await expect(sidebar.getByLabel('Gruppens navn')).toBeVisible();
|
||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||
|
||||
const painted = await sidebar.locator('.folder-head .group-dot').evaluate((el) =>
|
||||
getComputedStyle(el).backgroundColor
|
||||
);
|
||||
const painted = await sidebar
|
||||
.locator('.folder-head .group-dot')
|
||||
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
// The heading itself is *not* indented — only what is filed under it. A heading
|
||||
// that starts further in than the rows above it says the heading is inside
|
||||
// something, which it is not.
|
||||
const headLeft = await sidebar
|
||||
.locator('.folder-head')
|
||||
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
||||
const looseLeft = await sidebar
|
||||
.locator('.views a')
|
||||
.first()
|
||||
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
||||
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
|
||||
|
||||
// A project inside a group starts further in than a row outside one. Measured
|
||||
// rather than asserted on a class, because indentation is a fact about where
|
||||
// the row's contents land — and measured at the *content* edge, not the box:
|
||||
@@ -337,14 +389,14 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me
|
||||
const name = sidebar.getByLabel('Gruppens navn');
|
||||
await name.fill('Kontoret');
|
||||
await name.press('Enter');
|
||||
await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeVisible();
|
||||
|
||||
// Deleting the heading must not take the project filed under it. It is
|
||||
// ungrouped here, which is the case that would look identical either way if
|
||||
// the assertion were only "the group is gone".
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await sidebar.getByRole('button', { name: 'Slet' }).click();
|
||||
await expect(sidebar.getByRole('button', { name: 'Kontoret', exact: true })).toBeHidden();
|
||||
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
|
||||
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
@@ -373,6 +425,11 @@ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning sige
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Risteriet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
// Waited for. Creating a project navigates, and `goto` is a promise nobody here
|
||||
// is holding — quick add fired before it landed puts the task in the Inbox with
|
||||
// no date, where the Today view is right not to show it. Intermittent, and it
|
||||
// reads as the quick add having failed.
|
||||
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Ny opgave').fill('brænde kaffe');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
@@ -425,6 +482,8 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Ærinder');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
// Waited for before navigating away, or the create can lose its own redirect.
|
||||
await expect(sidebar.getByRole('link', { name: 'Ærinder' })).toBeVisible();
|
||||
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||
@@ -438,6 +497,14 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
|
||||
await expect(day('I morgen').getByText('hent pakken')).toBeVisible();
|
||||
await expect(day('I dag').getByText('hent pakken')).toBeHidden();
|
||||
|
||||
// Onto "I dag" in the sidebar: the most-made rescheduling there is, and the
|
||||
// sidebar is where the pointer already is. "Kommende" deliberately does not
|
||||
// take one — it is a range, not a day, so a drop would have to invent a date
|
||||
// its label does not name.
|
||||
await row.dragTo(sidebar.getByRole('link', { name: 'I dag' }));
|
||||
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
|
||||
await row.dragTo(day('I morgen'));
|
||||
|
||||
// And onto a project, which is a move rather than a field: the task has to
|
||||
// find a place among that project's tasks, and leave its old section behind.
|
||||
await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' }));
|
||||
@@ -450,7 +517,7 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async (
|
||||
// browser is pinned to Europe/Copenhagen and this process is not, and for two
|
||||
// hours of every day they disagree about what today is.
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Kalender' }).click();
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
|
||||
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
|
||||
await expect(chip).toBeVisible();
|
||||
@@ -492,7 +559,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Del' }).click();
|
||||
await projectAction(page, 'Del');
|
||||
await page.getByLabel('Inviter via e-mail').fill('[email protected]');
|
||||
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||
|
||||
@@ -727,8 +794,11 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Fælles');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
// Waited for, like everywhere else here: creating a project navigates, and the
|
||||
// controls below only exist once it has.
|
||||
await expect(page.getByRole('heading', { name: 'Fælles' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Del' }).click();
|
||||
await projectAction(page, 'Del');
|
||||
await page.getByLabel('Inviter via e-mail').fill('[email protected]');
|
||||
await page.getByLabel('Rolle').selectOption('viewer');
|
||||
await page.getByRole('button', { name: 'Inviter' }).click();
|
||||
@@ -753,7 +823,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
// The owner promotes them from the member list. The owner's own row is text,
|
||||
// not a dropdown: ownership is transferred, not granted.
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Delt/ }).click();
|
||||
await projectAction(page, /Delt/);
|
||||
await expect(page.getByText('Ejer')).toBeVisible();
|
||||
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
||||
|
||||
@@ -781,7 +851,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
).toHaveCount(0);
|
||||
|
||||
// The log has recorded all of this since the beginning and nothing showed it.
|
||||
await page.getByRole('button', { name: 'Historik' }).click();
|
||||
await projectAction(page, 'Historik');
|
||||
await expect(page.getByText('oprettede projektet')).toBeVisible();
|
||||
await expect(page.getByText('ændrede rollen for')).toBeVisible();
|
||||
|
||||
@@ -789,6 +859,763 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A task dragged across a month boundary.
|
||||
*
|
||||
* The month grid could not do this. It is anchored to a month, so the two days on
|
||||
* either side of its edge sit in different grids, and getting from one to the other
|
||||
* means paging — which cannot be done mid-drag, because a drag in flight swallows
|
||||
* the click that would page it. The week view exists for exactly this: a week that
|
||||
* straddles the 31st and the 1st has both days in the same row.
|
||||
*
|
||||
* The dates are worked out in the browser, not here. Playwright pins the page to
|
||||
* Europe/Copenhagen and this process is not pinned to anything, so for two hours of
|
||||
* every day the two disagree about what today is.
|
||||
*/
|
||||
test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
// The first day, starting tomorrow, whose next day is in another month *and* in
|
||||
// the same Monday-to-Sunday week. Such a pair exists at every month boundary
|
||||
// except one that falls between a Sunday and a Monday, so searching forward
|
||||
// finds one within about two months whatever today is.
|
||||
const { from, to } = await page.evaluate(() => {
|
||||
const iso = (d) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
const monday = (d) => {
|
||||
const m = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
m.setDate(m.getDate() - ((m.getDay() + 6) % 7));
|
||||
return iso(m);
|
||||
};
|
||||
const day = new Date();
|
||||
for (let i = 0; i < 90; i++) {
|
||||
day.setDate(day.getDate() + 1);
|
||||
const next = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
|
||||
if (day.getMonth() !== next.getMonth() && monday(day) === monday(next)) {
|
||||
return { from: iso(day), to: iso(next) };
|
||||
}
|
||||
}
|
||||
throw new Error('no month boundary inside a week in the next 90 days');
|
||||
});
|
||||
|
||||
const box = page.getByLabel('Ny opgave');
|
||||
await box.fill(`skifte dæk ${from}`);
|
||||
await box.press('Enter');
|
||||
// Not asserted here: this is the Today view and the task is dated weeks out, so
|
||||
// it is correctly absent. The week grid below is where it has to turn up.
|
||||
await expect(box).toHaveValue('');
|
||||
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
|
||||
// Forward a week at a time until the row is the one holding that date. Bounded,
|
||||
// because a loop that pages forever on a broken button is a timeout with no
|
||||
// explanation in it.
|
||||
const cell = page.locator(`[data-date="${from}"]`);
|
||||
const next = page.getByRole('button', { name: 'Næste uge' });
|
||||
for (let i = 0; i < 15 && (await cell.count()) === 0; i++) {
|
||||
await next.click();
|
||||
}
|
||||
await expect(cell, `uge-visningen nåede aldrig frem til ${from}`).toBeVisible();
|
||||
|
||||
// The claim the view is for: both sides of the month boundary, in one row.
|
||||
await expect(page.locator(`[data-date="${to}"]`)).toBeVisible();
|
||||
expect(from.slice(0, 7), 'de to dage skulle ligge i hver sin måned').not.toEqual(to.slice(0, 7));
|
||||
|
||||
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
|
||||
await expect(chip).toBeVisible();
|
||||
await chip.dragTo(page.locator(`[data-date="${to}"]`));
|
||||
|
||||
await expect(page.locator(`[data-date="${to}"]`).getByText('skifte dæk')).toBeVisible();
|
||||
await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The instance-wide history.
|
||||
*
|
||||
* The per-project panel has shown the same rows for a while; this is the question
|
||||
* it cannot answer, which is what happened in a project the administrator is not a
|
||||
* member of. The test uses a second person's own project for exactly that reason —
|
||||
* asserting against a project the admin can already see would pass with the old
|
||||
* per-project endpoint behind it.
|
||||
*/
|
||||
test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({
|
||||
browser,
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
// A second account, with a project of its own. storageState is spelled out:
|
||||
// a "fresh" context inherits the signed-in one otherwise, which is the single
|
||||
// case this test is not about.
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const theirs = await context.newPage();
|
||||
const email = `historik-${Date.now()}@example.dk`;
|
||||
|
||||
await page.goto('/indstillinger/brugere');
|
||||
await page.getByLabel('E-mailadresse').fill(email);
|
||||
await page.getByRole('button', { name: 'Send invitation' }).click();
|
||||
const link = await page.locator('.link-out').textContent();
|
||||
expect(link).toContain('/invite?token=');
|
||||
|
||||
await theirs.goto(link);
|
||||
await theirs.getByLabel('Navn').fill('Sigrid');
|
||||
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await theirs.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
const sidebar = theirs.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Sigrids eget');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Sigrids eget' })).toBeVisible();
|
||||
|
||||
// The administrator cannot open that project, and can still see that it happened.
|
||||
await page.goto('/indstillinger/historik');
|
||||
const rows = page.locator('.rows li').filter({ hasText: 'Sigrids eget' });
|
||||
await expect(rows.first()).toBeVisible();
|
||||
await expect(rows.first().getByText('oprettede projektet')).toBeVisible();
|
||||
// `exact`, or this matches the project name "Sigrids eget" in the same row.
|
||||
await expect(rows.first().getByText('Sigrid', { exact: true })).toBeVisible();
|
||||
|
||||
// And the filters narrow it rather than emptying it.
|
||||
await page.getByLabel('Hændelse').selectOption('project.created');
|
||||
await expect(page.locator('.rows li').filter({ hasText: 'Sigrids eget' }).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Ryd' }).click();
|
||||
|
||||
await context.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The project page on a phone.
|
||||
*
|
||||
* Every other test here runs at desktop width, and this is what that misses: the
|
||||
* header is a flex row of a title and four controls, and 390px does not have room
|
||||
* for them. Without wrapping, the heading was squeezed to its min-content — which
|
||||
* with `overflow-wrap: anywhere` is one character — so a project called
|
||||
* "Skovvænget" rendered as a vertical column of letters while the buttons ran off
|
||||
* the right edge. It looked like a broken font.
|
||||
*/
|
||||
test('projektsiden er brugbar på en telefon', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
// The sidebar is a drawer at this width, so the project is reached through it.
|
||||
await page.getByRole('button', { name: 'Vis menu' }).click();
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Skovvænget');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
|
||||
const heading = page.getByRole('heading', { name: 'Skovvænget' });
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
const box = await heading.boundingBox();
|
||||
// Wide enough to hold the word, and short enough not to be a column of
|
||||
// letters. One line of "Skovvænget" at --text-2xl is nowhere near 200px tall.
|
||||
expect(box.width, 'overskriften er klemt sammen').toBeGreaterThan(120);
|
||||
expect(box.height, 'overskriften er brudt op i én bogstavkolonne').toBeLessThan(120);
|
||||
|
||||
// And nothing sticks out past the right edge of the page.
|
||||
const sideways = () =>
|
||||
page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
||||
);
|
||||
expect(await sideways(), 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||
|
||||
// The task drawer, too. It was `width: 100vw`, which includes the scrollbar
|
||||
// gutter where a percentage does not, so the drawer came out wider than the
|
||||
// page and the whole document could be pushed sideways.
|
||||
await page.getByLabel('Ny opgave').fill('mal væggen');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('mal væggen', { exact: true }).click();
|
||||
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
||||
expect(await sideways(), 'opgaveruden skubber siden sidelæns').toBeLessThanOrEqual(1);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A group is somewhere you can go.
|
||||
*
|
||||
* It was a heading over some rows: a name, a colour, and whether it was folded.
|
||||
* Enough to tidy a list and not enough to be what people mean by it — "Arbejde" is
|
||||
* a body of work with context of its own, and the documents that belong to all of
|
||||
* it rather than to any one project inside it.
|
||||
*/
|
||||
test('en gruppe er en side med sine projekter, en beskrivelse og filer', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Ny gruppe' }).click();
|
||||
await sidebar.getByLabel('Gruppens navn').fill('Værksted');
|
||||
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
||||
await expect(sidebar.getByRole('link', { name: 'Værksted' })).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Drejebænk');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Drejebænk' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Ny opgave').fill('bestil stål');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(page.getByText('bestil stål', { exact: true })).toBeVisible();
|
||||
|
||||
// Into the group, then onto its page — which is what the heading is a link to
|
||||
// now. The chevron beside it still folds; they are two intentions and the old
|
||||
// single target could only serve the smaller one.
|
||||
await sidebar
|
||||
.getByRole('link', { name: 'Drejebænk' })
|
||||
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
||||
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
||||
const row = page.locator('.projects a').filter({ hasText: 'Drejebænk' });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByText('1', { exact: true }), 'antallet af åbne opgaver').toBeVisible();
|
||||
|
||||
// The description, which is the part a heading cannot carry. Click to write,
|
||||
// blur to save — the same shape a task's description has.
|
||||
await page.getByRole('button', { name: /Skriv hvad det her er/ }).click();
|
||||
const about = page.getByLabel('Om gruppen');
|
||||
await expect(about).toBeVisible();
|
||||
await about.fill('Alt der larmer og støver.');
|
||||
await about.blur();
|
||||
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
|
||||
|
||||
// And it survives a reload, which is the difference between the page keeping up
|
||||
// and the server taking it.
|
||||
await page.reload();
|
||||
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
|
||||
await expect(page.locator('.projects a').filter({ hasText: 'Drejebænk' })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The account's language changes the interface, not only the parser.
|
||||
*
|
||||
* `locale` has been on the account since the beginning and only chose which
|
||||
* grammar quick add read a line with — so picking English changed nothing anybody
|
||||
* could see, and the field said so honestly: "Sprog i hurtig tilføjelse".
|
||||
*
|
||||
* Asserted on both directions and on a date, because the date formatter is the
|
||||
* half that is easy to leave behind: `toLocaleDateString('da-DK', …)` was written
|
||||
* into a dozen components, and an English interface still saying "24. aug." is the
|
||||
* shape of a half-finished translation.
|
||||
*
|
||||
* It puts Danish back at the end. Everything after this reads Danish labels, and a
|
||||
* test that leaves the account in English fails the ones that follow it rather
|
||||
* than itself — which is the worst way for a suite to report anything.
|
||||
*/
|
||||
test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/indstillinger');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Sprog').selectOption('en');
|
||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||
|
||||
// The sidebar redraws without a reload: i18n.locale is $state, which is the
|
||||
// whole reason that module is .svelte.js.
|
||||
const english = page.getByRole('navigation', { name: 'Main menu' });
|
||||
await expect(english.getByRole('link', { name: 'Today' })).toBeVisible();
|
||||
await expect(english.getByRole('link', { name: 'Upcoming' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
|
||||
|
||||
// And it survives a reload, which is the difference between the page keeping up
|
||||
// and the account actually holding it.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Today' })
|
||||
).toBeVisible();
|
||||
|
||||
// A date, in the half that is easy to leave in Danish. The month grid's heading
|
||||
// is `toLocaleDateString`, which was `'da-DK'` in a dozen components before
|
||||
// `tag()` existed — an English interface still saying "december" is exactly the
|
||||
// shape of a half-finished translation.
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Month', exact: true }).click();
|
||||
await expect(page.locator('.calendar h2')).not.toContainText(
|
||||
/januar|februar|marts|maj|juni|juli|oktober|december/i
|
||||
);
|
||||
|
||||
// The Inbox keeps its name: it is a project row created at signup, not a
|
||||
// string in the interface, and the person can rename it like any other.
|
||||
|
||||
// Danish again, for everything that runs after this.
|
||||
await page.goto('/indstillinger');
|
||||
await page.getByLabel('Language').selectOption('da');
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'I dag' })
|
||||
).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Finished tasks can be looked at, and are out of the way by default.
|
||||
*
|
||||
* A task list is what is left to do, so closing something has to remove it from
|
||||
* the plan — but "what did I get done" is a real question and the answer was
|
||||
* nowhere. They come back in one list at the bottom rather than back among the
|
||||
* open ones in their sections: a closed task has stopped being work and started
|
||||
* being a record, and putting the record back in the middle makes the plan longer
|
||||
* without making it say more.
|
||||
*
|
||||
* The setting is in localStorage, so this also checks it survives a reload — the
|
||||
* half that a toggle held only in component state would fail.
|
||||
*/
|
||||
test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Loftet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Loftet' })).toBeVisible();
|
||||
|
||||
for (const what of ['rydde op', 'male gavlen']) {
|
||||
await page.getByLabel('Ny opgave').fill(what);
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(page.getByText(what, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// Close one. It leaves the list, which is the behaviour being protected here.
|
||||
await page
|
||||
.locator('.row')
|
||||
.filter({ hasText: 'rydde op' })
|
||||
.getByRole('button', { name: 'Markér som færdig' })
|
||||
.click();
|
||||
await expect(page.getByText('rydde op', { exact: true })).toBeHidden();
|
||||
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
||||
|
||||
// And it can be looked at again, under its own heading at the bottom.
|
||||
await projectAction(page, 'Vis færdige');
|
||||
const done = page.locator('section.done');
|
||||
await expect(done.getByRole('heading', { name: 'Færdige' })).toBeVisible();
|
||||
await expect(done.getByText('rydde op', { exact: true })).toBeVisible();
|
||||
// Still exactly where it was in the plan — reopened tasks are the only ones
|
||||
// that come back up.
|
||||
await expect(done.getByText('male gavlen', { exact: true })).toHaveCount(0);
|
||||
|
||||
// The choice is in localStorage, so it holds across a reload.
|
||||
await page.reload();
|
||||
await expect(page.locator('section.done').getByText('rydde op', { exact: true })).toBeVisible();
|
||||
|
||||
await projectAction(page, 'Skjul færdige');
|
||||
await expect(page.locator('section.done')).toHaveCount(0);
|
||||
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A section holds more than one task, and stops being highlighted afterwards.
|
||||
*
|
||||
* Both reported from use, and both the same root cause. TaskList read the dragged
|
||||
* task from its *own* `draggingId`, which is only set when the drag started in
|
||||
* that list — so a task dragged in from the unsectioned rows arrived as null and
|
||||
* the handler returned early. It had already called `stopPropagation`, so the
|
||||
* section around it never got the drop either.
|
||||
*
|
||||
* The result was a section you could only drop into while it was empty, because
|
||||
* an empty one has no rows to aim at. Put one task in, and there was no way to add
|
||||
* a second: it looked exactly like a section that holds one task.
|
||||
*
|
||||
* The same `stopPropagation` left the section's frame lit, since the handler that
|
||||
* clears it never ran.
|
||||
*/
|
||||
test('en sektion kan rumme flere opgaver, og rammen forsvinder bagefter @forms', async ({
|
||||
page
|
||||
}) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Badeværelset');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Badeværelset' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
await page.getByLabel('Ny sektion').fill('Håndværker');
|
||||
await page.getByLabel('Ny sektion').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Håndværker' })).toBeVisible();
|
||||
|
||||
for (const what of ['knager op', 'fuge om vinduet']) {
|
||||
await page.getByLabel('Ny opgave').fill(what);
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(page.getByText(what, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
const section = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Håndværker' })
|
||||
});
|
||||
const row = (what) => page.locator('.row').filter({ hasText: what });
|
||||
|
||||
// The first goes in while the section is empty — the only drop that used to
|
||||
// work, because there was no row in the way to swallow it.
|
||||
await row('knager op').dragTo(section);
|
||||
await expect(section.getByText('knager op', { exact: true })).toBeVisible();
|
||||
|
||||
// And the second, aimed at the row that is now in there. This is the one that
|
||||
// did nothing at all.
|
||||
await row('fuge om vinduet').dragTo(section.getByText('knager op', { exact: true }));
|
||||
await expect(section.getByText('fuge om vinduet', { exact: true })).toBeVisible();
|
||||
await expect(section.locator('.row')).toHaveCount(2);
|
||||
|
||||
// The frame is a drop target being offered, not a state. Once the drag is over
|
||||
// it has nothing left to say.
|
||||
await expect(section).not.toHaveClass(/\bover\b/);
|
||||
|
||||
// It survives a reload, so this is the server's answer and not the page's.
|
||||
await page.reload();
|
||||
const after = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Håndværker' })
|
||||
});
|
||||
await expect(after.locator('.row')).toHaveCount(2);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The project header keeps its name readable.
|
||||
*
|
||||
* It was a flex row of eight controls and a heading, and the heading was the part
|
||||
* that gave way: "GarageRisteriet" rendered as "GarageRist / eriet", broken
|
||||
* mid-word, because `overflow-wrap: anywhere` breaks at the first opportunity
|
||||
* rather than the last necessary one. A name split mid-syllable reads as a
|
||||
* rendering fault, not as a wrap.
|
||||
*
|
||||
* Everything but the view switcher lives behind one button now. This measures the
|
||||
* heading rather than counting controls, because the thing being protected is that
|
||||
* the name is legible — not any particular arrangement of the row.
|
||||
*/
|
||||
test('projektets navn brydes ikke midt i et ord', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('GarageRisteriet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
|
||||
const heading = page.getByRole('heading', { name: 'GarageRisteriet', level: 1 });
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
// One line. The name is fifteen characters and the row now has room for it,
|
||||
// which is the whole point of moving the rest behind a button.
|
||||
const box = await heading.boundingBox();
|
||||
const lineHeight = await heading.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
|
||||
expect(box.height, 'overskriften er brudt over flere linjer').toBeLessThan(lineHeight * 2);
|
||||
|
||||
// And the actions are still reachable, just one click further in.
|
||||
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
||||
for (const item of ['Vis færdige', 'Del', 'Historik', 'Farve på projektet', 'Slet projektet']) {
|
||||
await expect(page.getByRole('menuitem', { name: item, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// Escape closes it — a menu you can only leave by pressing its own button again
|
||||
// is one you click twice to get out of.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('menuitem', { name: 'Historik', exact: true })).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A task can be written straight into a section, two ways.
|
||||
*
|
||||
* The box at the top of the page puts things in the project with no section, which
|
||||
* is right for capturing — but a project has sections because the work belongs
|
||||
* somewhere, and dragging every new task down into the right one is a second
|
||||
* gesture for something you already knew when you typed it.
|
||||
*
|
||||
* The field at the foot of a section passes the section it sits in. The sigil is
|
||||
* for the box at the top, and for Today and Upcoming, where you are not standing
|
||||
* in a section at all.
|
||||
*/
|
||||
test('en opgave kan skrives direkte i en sektion @forms', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Risteriet');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
||||
|
||||
// A section whose name contains the sigil, because that is the one that would
|
||||
// break: the bare form has to be greedy enough to swallow the whole name.
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
await page.getByLabel('Ny sektion').fill('Kunder/Ordrer');
|
||||
await page.getByLabel('Ny sektion').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Kunder/Ordrer' })).toBeVisible();
|
||||
|
||||
const section = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: 'Kunder/Ordrer' })
|
||||
});
|
||||
|
||||
// The field at the foot of the section. No sigil — standing there is enough.
|
||||
await section.getByRole('button', { name: '+ Tilføj opgave' }).click();
|
||||
const field = page.getByLabel('Ny opgave i Kunder/Ordrer');
|
||||
await field.fill('Karpenhøj bestilling i morgen p1');
|
||||
await field.press('Enter');
|
||||
|
||||
const landed = section.locator('.row').filter({ hasText: 'Karpenhøj bestilling' });
|
||||
await expect(landed).toBeVisible();
|
||||
// It went through quick add, so the date and the priority were read as well.
|
||||
await expect(landed.getByText('I morgen')).toBeVisible();
|
||||
|
||||
// And the sigil, from the box at the top, which is not standing anywhere.
|
||||
await page.getByLabel('Ny opgave').fill('nye poser /Kunder/Ordrer');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await expect(section.locator('.row').filter({ hasText: 'nye poser' })).toBeVisible();
|
||||
// The sigil is consumed, not left in the title.
|
||||
await expect(section.getByText('/Kunder/Ordrer')).toHaveCount(0);
|
||||
|
||||
await expect(section.locator('.row')).toHaveCount(2);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A task closed by mistake can be got back, two ways.
|
||||
*
|
||||
* Reported from use: "I just closed a task by accident, and there is no undo and no
|
||||
* list of closed tasks." Both halves were true. Completing is one click on a small
|
||||
* circle beside a row you were only reading, and it takes the row off the screen —
|
||||
* so the mistake and the evidence of it leave together.
|
||||
*
|
||||
* The toast covers the mistake you notice at once. Færdige covers the one you
|
||||
* notice tomorrow, and it is a place rather than a toggle because somebody hunting
|
||||
* for a task they closed does not know which view they closed it in.
|
||||
*/
|
||||
test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByLabel('Ny opgave').fill('rens tagrenden i dag');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
const row = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Closed by mistake.
|
||||
await row.getByRole('button', { name: 'Markér som færdig' }).click();
|
||||
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
||||
|
||||
// The toast names it, so you can tell which one you hit.
|
||||
const toast = page.locator('.toast').filter({ hasText: 'rens tagrenden' });
|
||||
await expect(toast).toBeVisible();
|
||||
await toast.getByRole('button', { name: 'Fortryd' }).click();
|
||||
await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toBeVisible();
|
||||
|
||||
// Now the other half: close it again and walk away from the toast.
|
||||
await page
|
||||
.locator('.row')
|
||||
.filter({ hasText: 'rens tagrenden' })
|
||||
.getByRole('button', { name: 'Markér som færdig' })
|
||||
.click();
|
||||
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('link', { name: 'Færdige' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible();
|
||||
|
||||
const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
||||
await expect(closed).toBeVisible();
|
||||
// Grouped by the day it was closed, which is how anybody would describe it.
|
||||
await expect(page.getByRole('heading', { name: 'I dag', level: 2 })).toBeVisible();
|
||||
|
||||
// And it reopens from here, leaving the list at once.
|
||||
await closed.getByRole('button', { name: 'Genåbn opgave' }).click();
|
||||
await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toHaveCount(0);
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The sidebar never scrolls sideways.
|
||||
*
|
||||
* It grew a horizontal scrollbar on an ordinary desktop, under the whole menu.
|
||||
* The cause was one flex item: the account name had `overflow: hidden` and no
|
||||
* `min-width: 0`, so it refused to shrink below its content and pushed the row
|
||||
* past the column — and `overflow-y: auto` on the sidebar resolves to `auto` on
|
||||
* the other axis too, which turned "a row that is too wide" into a scrollbar.
|
||||
*
|
||||
* Asserted with a long name, because the name is the part that varies and a short
|
||||
* one hides the whole class of bug. The mobile test measures the *document*; this
|
||||
* measures the sidebar itself, which is a different box and was never checked.
|
||||
*/
|
||||
test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/indstillinger');
|
||||
|
||||
// A name long enough to overflow the column at its default width.
|
||||
await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard');
|
||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
|
||||
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||
|
||||
// And the whole page, for the same reason at a different scale.
|
||||
const page_sideways = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
||||
);
|
||||
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||
|
||||
// Put the name back, so the tests after this one see the account they expect.
|
||||
await page.getByLabel('Navn').fill(USER.name);
|
||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A project can have more than one section.
|
||||
*
|
||||
* Reported from use: sections "have no function, because you cannot create more
|
||||
* than one". The API makes as many as you ask for — there is a Go test that makes
|
||||
* three — so whatever this is, it is in the page. Every other test here makes
|
||||
* exactly one section, which is the shape of test that cannot see it.
|
||||
*/
|
||||
test('et projekt kan have flere sektioner @forms', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||
await sidebar.getByLabel('Projektnavn').fill('Ombygning');
|
||||
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Ombygning' })).toBeVisible();
|
||||
|
||||
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
const field = page.getByLabel('Ny sektion');
|
||||
await expect(field, `feltet kom ikke frem for "${name}"`).toBeVisible();
|
||||
await field.fill(name);
|
||||
await field.press('Enter');
|
||||
await expect(
|
||||
page.getByRole('heading', { name, exact: true }),
|
||||
`sektionen "${name}" kom ikke på siden`
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// All three at once, still there — an each-block that threw would have stopped
|
||||
// rendering at the one that broke it.
|
||||
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
|
||||
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// And they survive a reload, which is the difference between "the page kept up"
|
||||
// and "the server took it".
|
||||
await page.reload();
|
||||
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
|
||||
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// The board is where somebody actually looks for a section, because a column on
|
||||
// a board *is* one — and it had no way to make one at all. Reported as
|
||||
// "sections have no function: there is no way to create them", which from
|
||||
// inside a board was exactly true.
|
||||
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
const field = page.getByLabel('Ny sektion');
|
||||
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
|
||||
await field.fill('Afsluttet');
|
||||
await field.press('Enter');
|
||||
await expect(page.getByRole('heading', { name: 'Afsluttet', exact: true })).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Afsluttet', exact: true })).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Form fields on a touch device are at least 16px.
|
||||
*
|
||||
* Not a matter of taste. Below 16px, iOS Safari zooms the page in when a field
|
||||
* takes focus and does not zoom back out when it is blurred — so the person is
|
||||
* left with the whole interface too large and has to pinch it back by hand, once
|
||||
* per field they tap. The type scale tops out at 15px, so every input in the app
|
||||
* did it; the task drawer worst, being four fields deep.
|
||||
*
|
||||
* Chromium cannot reproduce the zoom, but it can be asked the question that causes
|
||||
* it, which is what this checks. A touch context rather than a narrow viewport:
|
||||
* the rule is keyed on `(hover: none) and (pointer: coarse)`, because an iPad does
|
||||
* the same thing at a width no phone breakpoint would catch.
|
||||
*/
|
||||
test('felter er mindst 16px, så iOS ikke zoomer ind og bliver der', async ({ browser }) => {
|
||||
const phone = { viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true };
|
||||
|
||||
const tooSmall = async (page, where) => {
|
||||
const bad = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('input, textarea, select')]
|
||||
.filter((el) => el.getClientRects().length)
|
||||
.map((el) => ({
|
||||
what: el.tagName.toLowerCase() + (el.type ? `[type=${el.type}]` : ''),
|
||||
size: parseFloat(getComputedStyle(el).fontSize)
|
||||
}))
|
||||
.filter((f) => f.size < 16)
|
||||
);
|
||||
expect(bad, `for små felter på ${where}`).toEqual([]);
|
||||
};
|
||||
|
||||
// The sign-in form, in a context with the session spelled out as empty —
|
||||
// newContext inherits the project's storageState, so a "fresh" browser is
|
||||
// otherwise already signed in and the form never renders.
|
||||
const signedOut = await browser.newContext({
|
||||
...phone,
|
||||
storageState: { cookies: [], origins: [] }
|
||||
});
|
||||
const anon = await signedOut.newPage();
|
||||
await anon.goto('/');
|
||||
await expect(anon.getByRole('button', { name: 'Log ind' })).toBeVisible();
|
||||
await tooSmall(anon, 'login-siden');
|
||||
await signedOut.close();
|
||||
|
||||
const context = await browser.newContext(phone);
|
||||
const page = await context.newPage();
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByLabel('Ny opgave')).toBeVisible();
|
||||
await tooSmall(page, 'I dag');
|
||||
|
||||
// The task drawer, which is where it was worst: four fields deep. Dated today,
|
||||
// or it goes to the Inbox and is correctly absent from the view being looked at.
|
||||
await page.getByLabel('Ny opgave').fill('slibe gulvet i dag');
|
||||
await page.getByLabel('Ny opgave').press('Enter');
|
||||
await page.getByText('slibe gulvet', { exact: true }).click();
|
||||
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
||||
await tooSmall(page, 'opgavedetaljer');
|
||||
|
||||
// And the settings pages, which are almost entirely fields.
|
||||
await page.goto('/indstillinger');
|
||||
await expect(page.locator('section.panel').first()).toBeVisible();
|
||||
await tooSmall(page, 'indstillinger');
|
||||
|
||||
await context.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The MCP connector address must not fall through to the app shell.
|
||||
*
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"svelte": "^5.1.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"vite": "^5.4.0"
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.70.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"svelte": "^5.56.9",
|
||||
"svelte-check": "^4.7.6",
|
||||
"vite": "^8.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,32 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
},
|
||||
/**
|
||||
* A short WebKit pass over the forms, and nothing else.
|
||||
*
|
||||
* The rest of this suite is Chromium only on purpose: a smoke test exists to
|
||||
* catch a build that does not work, and running it in three engines finds the
|
||||
* same bug three times. Focus is the exception, and it cost a real report to
|
||||
* learn it. Safari ignores the `autofocus` attribute on anything inserted
|
||||
* after the page has loaded — so every inline form in this app opened with no
|
||||
* cursor in it, and what you typed went nowhere. It does not look like a bug;
|
||||
* it looks like the feature does not work. "Sections have no function,
|
||||
* because you cannot create more than one" was this.
|
||||
*
|
||||
* One test, not four. The engines share this server and this database, so a
|
||||
* second pass over tests that create projects by name collides with what the
|
||||
* first pass already made — which fails as a name that is suddenly ambiguous
|
||||
* rather than as anything about WebKit. The sections test is the one that
|
||||
* earns the second engine, because it is the one the focus bug broke.
|
||||
*
|
||||
* WebKit here is the same engine Safari is, which is the whole point.
|
||||
*/
|
||||
{
|
||||
name: 'webkit-forms',
|
||||
grep: /@forms/,
|
||||
use: { ...devices['Desktop Safari'], storageState: 'e2e/.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Renders static/icon.svg to the PNGs the manifest and iOS need.
|
||||
*
|
||||
* node scripts/generate-icons.js
|
||||
*
|
||||
* A script rather than a test, so CI never runs it and never rewrites a tracked
|
||||
* binary. Run it when the icon changes; the smoke test fetches every icon the
|
||||
* manifest names, so a reference with no file behind it fails there.
|
||||
*
|
||||
* It drives Chromium because there is no SVG renderer on this machine — which is
|
||||
* why these references were once removed rather than left pointing at files that
|
||||
* did not exist. Playwright is a renderer, and it is already a dependency.
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const SIZES = [
|
||||
// The two the manifest asks for: 192 for a launcher, 512 for a splash screen.
|
||||
{ file: 'static/icon-192.png', size: 192 },
|
||||
{ file: 'static/icon-512.png', size: 512 },
|
||||
// And the one Safari wants by name. It ignores the manifest's icons entirely
|
||||
// and looks for this, which is why an installed PWA on iOS otherwise gets a
|
||||
// screenshot of the page as its home-screen icon.
|
||||
{ file: 'static/apple-touch-icon.png', size: 180 }
|
||||
];
|
||||
|
||||
const svg = readFileSync('static/icon.svg', 'utf8');
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
for (const { file, size } of SIZES) {
|
||||
await page.setViewportSize({ width: size, height: size });
|
||||
await page.setContent(
|
||||
`<style>html,body{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`
|
||||
);
|
||||
writeFileSync(file, await page.locator('svg').screenshot());
|
||||
console.log(`${file} ${size}×${size}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Captures the screenshots the landing page, the README and the docs use.
|
||||
*
|
||||
* node scripts/generate-screenshots.js
|
||||
*
|
||||
* A script rather than a test, for the same reason as generate-icons.js: CI must
|
||||
* never rewrite a tracked binary file. Run it when the interface changes.
|
||||
*
|
||||
* It replaces the hand-driven half of `go run ./tools/shots`, which needs a
|
||||
* running instance and a session cookie pasted in by hand — and which therefore
|
||||
* ran rarely enough that the images fell many rounds behind the app they show.
|
||||
* This one starts the real binary, creates its own account, fills it with the
|
||||
* demo data, and takes the pictures. Nothing to set up and nothing to remember.
|
||||
*
|
||||
* The binary, not the dev server: the screenshots should show what ships. That
|
||||
* means building the frontend and copying it into cmd/verdande/webbuild first,
|
||||
* exactly as the smoke tests do — a capture of a stale embedded build would be a
|
||||
* picture of an app nobody is running.
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const web = join(here, '..');
|
||||
const repo = join(web, '..');
|
||||
const dataDir = join(web, '.screenshot-data');
|
||||
|
||||
const PORT = 8098;
|
||||
const baseURL = `http://127.0.0.1:${PORT}`;
|
||||
const USER = { name: 'Kristian', email: '[email protected]', password: 'et langt kodeord' };
|
||||
|
||||
// 1400×900 is what the landing page's <img> declares. A capture at another size
|
||||
// would be resampled by the browser and look soft next to the text beside it.
|
||||
const WIDTH = 1400;
|
||||
const HEIGHT = 900;
|
||||
|
||||
const OUT = [join(repo, 'site', 'screenshots'), join(repo, 'docs', 'screenshots')];
|
||||
|
||||
/**
|
||||
* The pictures, and why each one is here.
|
||||
*
|
||||
* Every view that carries a claim the prose makes. A screenshot set that stops at
|
||||
* "the task list" is a set that cannot show anything the app grew since.
|
||||
*/
|
||||
const SHOTS = [
|
||||
{ name: 'today', path: '/', theme: 'dark' },
|
||||
{ name: 'today-light', path: '/', theme: 'light' },
|
||||
{ name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
|
||||
{ name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
|
||||
{ name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
|
||||
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
|
||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
|
||||
{ name: 'admin', path: '/indstillinger/brugere', theme: 'dark' },
|
||||
{ name: 'history', path: '/indstillinger/historik', theme: 'dark' },
|
||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
|
||||
];
|
||||
|
||||
async function showMonth(page) {
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
async function showWeek(page) {
|
||||
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
async function openFirstTask(page) {
|
||||
await page.locator('.row').first().click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
console.log('building the frontend and embedding it…');
|
||||
execSync('npm run build', { cwd: web, stdio: 'inherit' });
|
||||
rmSync(join(repo, 'cmd', 'verdande', 'webbuild'), { recursive: true, force: true });
|
||||
execSync(`cp -r ${JSON.stringify(join(web, 'build'))} ${JSON.stringify(join(repo, 'cmd', 'verdande', 'webbuild'))}`);
|
||||
|
||||
// A clean instance: the first screen is "create the first account", which only
|
||||
// appears on an empty database.
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
|
||||
console.log('starting the binary…');
|
||||
// `detached`, so the whole process group can be killed at the end. `go run`
|
||||
// compiles the binary and runs it as a *child*: killing `go run` leaves the server
|
||||
// itself holding the port, and the next run then dies on "address already in use"
|
||||
// with no clue as to what is holding it.
|
||||
const server = spawn('go', ['run', '-tags', 'embedweb', './cmd/verdande'], {
|
||||
cwd: repo,
|
||||
detached: true,
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
env: {
|
||||
...process.env,
|
||||
VERDANDE_ADDR: `:${PORT}`,
|
||||
VERDANDE_BASE_URL: baseURL,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// An update check here would reach out to GitHub to take a screenshot.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
}
|
||||
});
|
||||
|
||||
let browser;
|
||||
try {
|
||||
await waitForHealth();
|
||||
|
||||
browser = await chromium.launch();
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: WIDTH, height: HEIGHT },
|
||||
// The same pinning the smoke tests use, and for the same reason: the sign-up
|
||||
// form sends navigator.language as the account's locale, and every due date
|
||||
// is resolved in the account's zone.
|
||||
locale: 'da-DK',
|
||||
timezoneId: 'Europe/Copenhagen',
|
||||
deviceScaleFactor: 2
|
||||
});
|
||||
|
||||
await page.goto(baseURL);
|
||||
await page.getByLabel(/Navn/).fill(USER.name);
|
||||
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||
await page.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).waitFor();
|
||||
|
||||
console.log('seeding…');
|
||||
const ids = await page.evaluate(seed);
|
||||
|
||||
mkdirSync(OUT[0], { recursive: true });
|
||||
mkdirSync(OUT[1], { recursive: true });
|
||||
|
||||
for (const shot of SHOTS) {
|
||||
const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
|
||||
await page.goto(baseURL + path);
|
||||
// A fixed settle rather than a load event: the interesting part is after
|
||||
// hydration and after the first fetch, and the load event says nothing
|
||||
// about either.
|
||||
await page.waitForTimeout(1200);
|
||||
if (shot.prepare) await shot.prepare(page);
|
||||
if (shot.theme) {
|
||||
await page.evaluate((t) => (document.documentElement.dataset.theme = t), shot.theme);
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
const png = await page.screenshot();
|
||||
writeFileSync(join(OUT[0], `${shot.name}.png`), png);
|
||||
copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`));
|
||||
console.log(` ${shot.name}.png`);
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
// The group, not the process: see the spawn above.
|
||||
try {
|
||||
process.kill(-server.pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Already gone, which is the outcome this was for.
|
||||
}
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function waitForHealth() {
|
||||
for (let i = 0; i < 240; i++) {
|
||||
try {
|
||||
const r = await fetch(`${baseURL}/healthz`);
|
||||
if (r.ok) return;
|
||||
} catch {
|
||||
// Not up yet. The first run compiles the binary, which takes a while.
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error('the server never came up');
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo data, created through the app's own API from inside the page.
|
||||
*
|
||||
* From the page rather than from node, so the session cookie and the same-origin
|
||||
* header the API insists on come along without being reconstructed here. Written
|
||||
* as one function because it is passed to the browser as source.
|
||||
*
|
||||
* The content is ordinary Danish household and work tasks. Screenshots of "Task
|
||||
* 1, Task 2, Task 3" show the layout and hide whether the app is any good at the
|
||||
* thing it is for.
|
||||
*/
|
||||
async function seed() {
|
||||
const api = async (path, body, method = 'POST') => {
|
||||
const r = await fetch(`/api/v1${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
|
||||
return r.status === 204 ? null : r.json();
|
||||
};
|
||||
|
||||
const day = (offset) => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + offset);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Two headings over the projects, which is what the sidebar grew for.
|
||||
const arbejde = await api('/project-groups', { name: 'Arbejde' });
|
||||
const privat = await api('/project-groups', { name: 'Privat' });
|
||||
await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH');
|
||||
await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH');
|
||||
|
||||
const work = await api('/projects', {
|
||||
name: 'Sæsonstart',
|
||||
color: 'indigo',
|
||||
view_mode: 'board',
|
||||
group_id: arbejde.id
|
||||
});
|
||||
const house = await api('/projects', { name: 'Huset', color: 'amber', group_id: privat.id });
|
||||
const garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.id });
|
||||
|
||||
const planning = await api(`/projects/${work.id}/sections`, { name: 'Planlægning' });
|
||||
const inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' });
|
||||
const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' });
|
||||
|
||||
const tasks = [
|
||||
// Today, in the Inbox and across the projects: the Today view is the first
|
||||
// picture and it has to look like a real day.
|
||||
{ content: 'Ring til tømreren om taget', due_date: day(0), priority: 1, project_id: house.id },
|
||||
{ content: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] },
|
||||
{
|
||||
content: 'Skriv oplæg til sæsonmødet',
|
||||
due_date: day(0),
|
||||
priority: 2,
|
||||
project_id: work.id,
|
||||
section_id: planning.id,
|
||||
duration_min: 90
|
||||
},
|
||||
{ content: 'Vand drivhuset', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
|
||||
{ content: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] },
|
||||
|
||||
// The next couple of weeks, so Kommende and the month grid have something in
|
||||
// them rather than a row of empty cells.
|
||||
{ content: 'Book håndværker til badeværelset', due_date: day(1), priority: 2, project_id: house.id },
|
||||
{ content: 'Beskær den grønne hæk', due_date: day(2), project_id: garden.id },
|
||||
{ content: 'Gennemgå budgettet', due_date: day(3), priority: 1, project_id: work.id, section_id: inFlight.id },
|
||||
{ content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id },
|
||||
{ content: 'Skift dæk', due_date: day(8), priority: 3 },
|
||||
{ content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id },
|
||||
{ content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 },
|
||||
{ content: 'Så vinterurter', due_date: day(15), project_id: garden.id },
|
||||
{ content: 'Kvartalsopfølgning', due_date: day(21), priority: 1, project_id: work.id, section_id: planning.id },
|
||||
{ content: 'Rens tagrenderne', due_date: day(26), project_id: house.id },
|
||||
|
||||
// A few without dates, so the board has cards that are not all deadlines.
|
||||
{ content: 'Find ny leverandør af emballage', project_id: work.id, section_id: planning.id, priority: 3 },
|
||||
{ content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id },
|
||||
{ content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] }
|
||||
];
|
||||
for (const task of tasks) await api('/tasks', task);
|
||||
|
||||
// A saved filter, because the sidebar shows them and an empty heading reads as
|
||||
// a feature that does not work.
|
||||
await api('/filters', { name: 'Vigtigt i denne uge', query: 'p1 & 7 days' });
|
||||
|
||||
return { work: work.id, house: house.id, garden: garden.id };
|
||||
}
|
||||
@@ -407,6 +407,32 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
/* Form fields are 16px on a touch device, whatever the component asked for.
|
||||
*
|
||||
* Below 16px, iOS Safari zooms the page in when a field takes focus — and it does
|
||||
* not zoom back out when the field is blurred. The person is left with the whole
|
||||
* interface too large and has to pinch it back themselves, once per field they
|
||||
* tap. The type scale here tops out at 15px, so every input in the app did it:
|
||||
* quick add, the sign-in form, and the task drawer worst of all, which is four
|
||||
* fields deep.
|
||||
*
|
||||
* `!important` on purpose. This is a browser workaround that has to beat every
|
||||
* component's own typography, and each of those is a scoped class that outranks
|
||||
* any selector this file could write. Narrowing it further would mean editing the
|
||||
* sixty-nine places that set a field's font size and adding a rule none of them
|
||||
* can be allowed to break.
|
||||
*
|
||||
* Touch rather than a width: an iPad in Safari does the same thing at a width no
|
||||
* phone breakpoint would catch.
|
||||
*/
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<!-- Safari ignores the manifest's icons entirely and looks for this by name.
|
||||
Without it an installed PWA on iOS gets a screenshot of the page as its
|
||||
home-screen icon. Generated from the same SVG by scripts/generate-icons.js. -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>verdande</title>
|
||||
<script>
|
||||
// Applied before first paint, so a dark-theme user never sees a white
|
||||
|
||||
@@ -22,46 +22,54 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Danish messages for the codes the API can return. The server sends English
|
||||
* prose for logs; what a person reads is decided here, where the locale is known. */
|
||||
import { t } from './i18n.svelte.js';
|
||||
|
||||
/**
|
||||
* The key for each code the API can return.
|
||||
*
|
||||
* Keys rather than sentences, because the server's own prose is English and
|
||||
* written for a log — what a person reads is decided here, where the locale is
|
||||
* known. `errorcodes_test.go` walks this table against the codes Go can emit, so
|
||||
* a code the server gained and this did not fails the build rather than falling
|
||||
* through to that log prose.
|
||||
*/
|
||||
const MESSAGES = {
|
||||
unauthorized: 'Forkert e-mail eller adgangskode.',
|
||||
totp_required: 'Indtast koden fra din authenticator.',
|
||||
forbidden: 'Det har du ikke adgang til.',
|
||||
not_found: 'Findes ikke.',
|
||||
conflict: 'Det er der allerede.',
|
||||
rate_limited: 'For mange forsøg. Vent et øjeblik.',
|
||||
validation_failed: 'Tjek felterne herunder.',
|
||||
payload_too_large: 'Det er for stort.',
|
||||
internal_error: 'Noget gik galt. Prøv igen.',
|
||||
bad_request: 'Anmodningen kunne ikke læses.',
|
||||
unauthorized: 'error.unauthorized',
|
||||
totp_required: 'error.totpRequired',
|
||||
forbidden: 'error.forbidden',
|
||||
not_found: 'error.notFound',
|
||||
conflict: 'error.conflict',
|
||||
rate_limited: 'error.rateLimited',
|
||||
validation_failed: 'error.validation',
|
||||
payload_too_large: 'error.tooLarge',
|
||||
internal_error: 'error.internal',
|
||||
bad_request: 'error.badRequest',
|
||||
// Client-only: a network failure is not an HTTP status, so the server has no
|
||||
// code for it.
|
||||
offline: 'Ingen forbindelse til serveren.',
|
||||
offline: 'error.offline',
|
||||
|
||||
// Situations that used to arrive as a plain `conflict` and were therefore
|
||||
// shown as "Det er der allerede." — which was not merely vague but wrong. An
|
||||
// shown as "that already exists" — which was not merely vague but wrong. An
|
||||
// unconfigured Gmail is not a Gmail that is already connected.
|
||||
gmail_not_configured:
|
||||
'Gmail er ikke sat op på denne server. Administratoren skal registrere en OAuth-klient hos Google og sætte VERDANDE_GMAIL_CLIENT_ID og _SECRET.',
|
||||
ai_not_configured: 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.',
|
||||
totp_not_enabled: 'To-faktor er ikke slået til.',
|
||||
inbox_protected: 'Indbakken kan ikke slettes.',
|
||||
last_admin:
|
||||
'Det er den sidste administrator. Gør en anden til administrator først — ellers er der ingen, der kan komme ind på denne side igen.'
|
||||
gmail_not_configured: 'error.gmailNotConfigured',
|
||||
ai_not_configured: 'error.aiNotConfigured',
|
||||
totp_not_enabled: 'error.totpNotEnabled',
|
||||
inbox_protected: 'error.inboxProtected',
|
||||
last_admin: 'error.lastAdmin'
|
||||
};
|
||||
|
||||
/**
|
||||
* What to show a person for a failed request.
|
||||
*
|
||||
* A code with no message here falls back to the server's prose, which is English
|
||||
* and written for a log — not good, but better than swallowing the only
|
||||
* explanation there is. A code that turns up in that fallback is a code this
|
||||
* table is missing.
|
||||
* A code with no key here falls back to the server's prose, which is English and
|
||||
* written for a log — not good, but better than swallowing the only explanation
|
||||
* there is. A code that turns up in that fallback is a code this table is missing.
|
||||
*/
|
||||
export function humanMessage(err) {
|
||||
if (!(err instanceof ApiError)) return MESSAGES.offline;
|
||||
return MESSAGES[err.code] ?? err.message ?? MESSAGES.internal_error;
|
||||
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
|
||||
const key = MESSAGES[err.code];
|
||||
if (key) return t(key);
|
||||
return err.message ?? t(MESSAGES.internal_error);
|
||||
}
|
||||
|
||||
async function request(method, path, body, options = {}) {
|
||||
@@ -160,6 +168,9 @@ export const api = {
|
||||
logout: () => post('/auth/logout'),
|
||||
me: () => get('/auth/me'),
|
||||
updateProfile: (data) => patch('/auth/me', data),
|
||||
// Its own route rather than a field on the profile: that one backs a form with
|
||||
// a save button, and this writes on every click of a chevron.
|
||||
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
|
||||
signup: (data) => post('/auth/signup', data),
|
||||
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
||||
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
|
||||
@@ -178,6 +189,34 @@ export const api = {
|
||||
setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }),
|
||||
deleteUser: (id) => del(`/users/${id}`),
|
||||
revokeInvite: (id) => del(`/invites/${id}`),
|
||||
listErrors: () => get('/errors'),
|
||||
|
||||
// The nightly backup, which ran since the beginning with nothing to show it.
|
||||
// The OAuth client itself, which is the instance's registration with Google
|
||||
// rather than anybody's mailbox.
|
||||
gmailClient: () => get('/gmail/client'),
|
||||
setGmailClient: (data) => put('/gmail/client', data),
|
||||
|
||||
listBackups: () => get('/backups'),
|
||||
runBackup: () => post('/backups'),
|
||||
// A link rather than a fetch: the browser's own download handles a file of
|
||||
// this size, and a blob in memory of the whole database does not.
|
||||
backupURL: (id) => `/api/v1/backups/${id}`,
|
||||
|
||||
// The instance-wide history, which is the other half of the error log: that
|
||||
// one is what broke, this is what was done. Paged with the cursor the server
|
||||
// hands back rather than an offset — see the OpenAPI note on /activity.
|
||||
auditLog: ({ before, user_id, project_id, event, limit } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (before) q.set('before', before);
|
||||
if (user_id) q.set('user_id', user_id);
|
||||
if (project_id) q.set('project_id', project_id);
|
||||
if (event) q.set('event', event);
|
||||
if (limit) q.set('limit', limit);
|
||||
const query = q.toString();
|
||||
return get(`/activity${query ? `?${query}` : ''}`);
|
||||
},
|
||||
auditEvents: () => get('/activity/events'),
|
||||
|
||||
recoveryCodes: () => get('/auth/recovery-codes'),
|
||||
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
|
||||
@@ -194,7 +233,9 @@ export const api = {
|
||||
|
||||
listProjectGroups: () => get('/project-groups'),
|
||||
createProjectGroup: (name) => post('/project-groups', { name }),
|
||||
getProjectGroup: (id) => get(`/project-groups/${id}`),
|
||||
updateProjectGroup: (id, data) => patch(`/project-groups/${id}`, data),
|
||||
uploadGroupAttachment: (id, file) => upload(`/project-groups/${id}/attachments`, file),
|
||||
deleteProjectGroup: (id) => del(`/project-groups/${id}`),
|
||||
reorderProjectGroups: (ids) => post('/project-groups/reorder', { ids }),
|
||||
|
||||
@@ -226,7 +267,8 @@ export const api = {
|
||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||
|
||||
quickAdd: (text, projectId) => post('/tasks/quick-add', { text, project_id: projectId }),
|
||||
quickAdd: (text, projectId, sectionId) =>
|
||||
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||
quickAddPreview: (text, signal) =>
|
||||
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
||||
|
||||
|
||||
@@ -21,17 +21,20 @@
|
||||
* carries a `--color-<id>` token for each, and a Go test reads this file to check
|
||||
* the server's list of accepted names has not drifted from it.
|
||||
*/
|
||||
// `name` is a key rather than a word: the picker calls `t()` on it. The ids stay
|
||||
// exactly as they are — they are what the database stores, and `internal/store`
|
||||
// has a test that reads them out of this file.
|
||||
export const COLORS = [
|
||||
{ id: 'graphite', name: 'Grafit' },
|
||||
{ id: 'tomato', name: 'Tomat' },
|
||||
{ id: 'amber', name: 'Rav' },
|
||||
{ id: 'lime', name: 'Lime' },
|
||||
{ id: 'green', name: 'Grøn' },
|
||||
{ id: 'teal', name: 'Petrol' },
|
||||
{ id: 'blue', name: 'Blå' },
|
||||
{ id: 'indigo', name: 'Indigo' },
|
||||
{ id: 'violet', name: 'Violet' },
|
||||
{ id: 'magenta', name: 'Magenta' }
|
||||
{ id: 'graphite', name: 'color.graphite' },
|
||||
{ id: 'tomato', name: 'color.tomato' },
|
||||
{ id: 'amber', name: 'color.amber' },
|
||||
{ id: 'lime', name: 'color.lime' },
|
||||
{ id: 'green', name: 'color.green' },
|
||||
{ id: 'teal', name: 'color.teal' },
|
||||
{ id: 'blue', name: 'color.blue' },
|
||||
{ id: 'indigo', name: 'color.indigo' },
|
||||
{ id: 'violet', name: 'color.violet' },
|
||||
{ id: 'magenta', name: 'color.magenta' }
|
||||
];
|
||||
|
||||
/** The CSS variable a colour id paints with, falling back to the default. */
|
||||
|
||||
@@ -9,11 +9,54 @@
|
||||
* them — and it gives keyboard users the browser's own behaviour for free.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import TaskRow from './TaskRow.svelte';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
let { project, sections, canEdit } = $props();
|
||||
let { project, sections, canEdit, onsectionadded } = $props();
|
||||
|
||||
// --- adding a column ------------------------------------------------------------
|
||||
//
|
||||
// A column on a board *is* a section, so this is where somebody looks for one.
|
||||
// It was only in the list view, which meant a board — the view whose entire
|
||||
// shape is its sections — had no way to grow one. Reported as "sections have no
|
||||
// function: there is no way to create them", which from inside a board is
|
||||
// exactly true.
|
||||
|
||||
let adding = $state(false);
|
||||
let name = $state('');
|
||||
|
||||
/** Same as the list view's: through quick add, so a date or a p1 still parses. */
|
||||
let addingIn = $state(null);
|
||||
let newInSection = $state('');
|
||||
|
||||
async function addToSection(event, sectionId) {
|
||||
event.preventDefault();
|
||||
const text = newInSection.trim();
|
||||
if (!text) return;
|
||||
newInSection = '';
|
||||
addingIn = null;
|
||||
try {
|
||||
app.upsert(await api.quickAdd(text, project.id, sectionId));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function addSection(event) {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
adding = false;
|
||||
name = '';
|
||||
try {
|
||||
onsectionadded?.(await api.createSection(project.id, trimmed));
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
let draggingId = $state(null);
|
||||
let overColumn = $state(null);
|
||||
@@ -118,11 +161,65 @@
|
||||
{/each}
|
||||
|
||||
{#if !tasks.length}
|
||||
<p class="empty">Tom</p>
|
||||
<p class="empty">{t('view.emptySection')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- At the foot of the column, where the next card would go. A board's
|
||||
columns *are* the sections, so this is the one place where "add a
|
||||
task here" needs no explanation at all. -->
|
||||
{#if canEdit}
|
||||
<!-- `column`, which is what the each binds. The unsectioned column has
|
||||
an empty id, and that is exactly what quick add wants for "in this
|
||||
project, no section". -->
|
||||
{#if addingIn === column.id}
|
||||
<form class="add-task" onsubmit={(e) => addToSection(e, column.id)}>
|
||||
<input
|
||||
bind:value={newInSection}
|
||||
use:focusOnMount
|
||||
placeholder={t('view.addTaskHere')}
|
||||
aria-label={t('view.addTaskIn', { name: column.name })}
|
||||
onblur={() => !newInSection.trim() && (addingIn = null)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add-task-open"
|
||||
onclick={() => {
|
||||
addingIn = column.id;
|
||||
newInSection = '';
|
||||
}}>{t('view.addTask')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#if canEdit}
|
||||
<section class="column adder">
|
||||
{#if adding}
|
||||
<form onsubmit={addSection}>
|
||||
<input
|
||||
bind:value={name}
|
||||
use:focusOnMount
|
||||
placeholder={t('view.sectionName')}
|
||||
aria-label={t('view.newSection')}
|
||||
onblur={() => !name.trim() && (adding = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="add"
|
||||
onclick={() => {
|
||||
adding = true;
|
||||
name = '';
|
||||
}}>{t('view.addSection')}</button
|
||||
>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -137,6 +234,43 @@
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
/* Narrower than a real column and without its background: it is an invitation
|
||||
at the end of the row, not a column with nothing in it. */
|
||||
.adder {
|
||||
background: none;
|
||||
border: 1px dashed var(--line);
|
||||
min-width: 180px;
|
||||
padding: var(--s2);
|
||||
}
|
||||
|
||||
.adder .add {
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.adder .add:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.adder input {
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.adder input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 0 0 300px;
|
||||
max-width: 300px;
|
||||
@@ -217,6 +351,36 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.add-task-open {
|
||||
align-self: flex-start;
|
||||
padding: var(--s2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.column:hover .add-task-open,
|
||||
.add-task-open:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-task-open:hover {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.add-task input {
|
||||
width: 100%;
|
||||
padding: var(--s2);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--s3) var(--s2);
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<script>
|
||||
/**
|
||||
* A month grid for one project.
|
||||
* A calendar grid: a whole month, or a single week.
|
||||
*
|
||||
* Weeks start on Monday. That is not a preference — it is ISO 8601 and it is
|
||||
* what every Danish calendar does, and a Sunday-first grid makes a Dane
|
||||
* misread the whole month at a glance.
|
||||
*
|
||||
* One component for both spans rather than two, because the part that is easy
|
||||
* to get subtly wrong is the same in both: the drag target has to decide from
|
||||
* `dataTransfer.types` alone whether it accepts a drop, and a second copy of
|
||||
* that is a second place for it to be almost right.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
|
||||
let {
|
||||
/**
|
||||
@@ -31,34 +37,82 @@
|
||||
* loaded, so a project's month has to say which project it is — Kommende,
|
||||
* which shows every project, is the one that leaves this out.
|
||||
*/
|
||||
projectId = null
|
||||
projectId = null,
|
||||
/**
|
||||
* 'month' for the whole month as six weeks, 'week' for one week as a single
|
||||
* row.
|
||||
*
|
||||
* The week exists for dragging across a month boundary. A month grid is
|
||||
* anchored to a month, so the two days on either side of its edge are in
|
||||
* different grids and moving a task between them means changing month
|
||||
* mid-drag — which the browser will not let you do, because a drag in
|
||||
* flight swallows the click that would page. A week that straddles the
|
||||
* 31st and the 1st has both days in the same row.
|
||||
*/
|
||||
span = 'month'
|
||||
} = $props();
|
||||
|
||||
let cursor = $state(startOfMonth(new Date()));
|
||||
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
|
||||
|
||||
/**
|
||||
* The weekday names, from Intl rather than written out.
|
||||
*
|
||||
* Not to save seven strings — so the abbreviations are the ones the language
|
||||
* actually uses. Every language shortens its weekdays differently, and guessing
|
||||
* at that from a translation table is how a calendar ends up saying something
|
||||
* no reader of that language would write.
|
||||
*
|
||||
* Monday first, because the grid is. The 1st of January 2024 was a Monday; any
|
||||
* Monday would do, and a constant one keeps this out of the render path.
|
||||
*/
|
||||
const WEEKDAYS = $derived.by(() => {
|
||||
const format = new Intl.DateTimeFormat(tag(), { weekday: 'short' });
|
||||
return Array.from({ length: 7 }, (_, i) => format.format(new Date(Date.UTC(2024, 0, 1 + i))));
|
||||
});
|
||||
|
||||
function startOfMonth(d) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
|
||||
// getDay() is Sunday-based; this converts to "days since Monday".
|
||||
function startOfWeek(d) {
|
||||
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
|
||||
return start;
|
||||
}
|
||||
|
||||
function startOfSpan(d) {
|
||||
return span === 'week' ? startOfWeek(d) : startOfMonth(d);
|
||||
}
|
||||
|
||||
// The cursor has to follow the span it is measured in: switching from month to
|
||||
// week with a cursor on the 1st would otherwise show the week containing the
|
||||
// 1st rather than the week you were looking at, and switching back would land
|
||||
// on whichever month that week happened to start in.
|
||||
$effect(() => {
|
||||
const aligned = startOfSpan(cursor);
|
||||
// Compared by value, not identity: assigning a fresh Date on every run would
|
||||
// re-trigger this effect forever, since the effect reads what it writes.
|
||||
if (aligned.getTime() !== cursor.getTime()) cursor = aligned;
|
||||
});
|
||||
|
||||
function iso(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
d.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// The grid always runs whole weeks, so it starts on the Monday on or before the
|
||||
// first of the month and ends on the Sunday on or after the last.
|
||||
// A month always runs whole weeks, so it starts on the Monday on or before the
|
||||
// first and ends on the Sunday on or after the last. A week is already one.
|
||||
let grid = $derived.by(() => {
|
||||
const first = startOfMonth(cursor);
|
||||
const start = new Date(first);
|
||||
// getDay() is Sunday-based; this converts to "days since Monday".
|
||||
start.setDate(first.getDate() - ((first.getDay() + 6) % 7));
|
||||
const start = span === 'week' ? startOfWeek(cursor) : startOfWeek(startOfMonth(cursor));
|
||||
// Six weeks covers every possible month layout, including a 31-day month
|
||||
// that begins on a Sunday.
|
||||
const length = span === 'week' ? 7 : 42;
|
||||
|
||||
const days = [];
|
||||
const cell = new Date(start);
|
||||
// Six weeks covers every possible month layout, including a 31-day month
|
||||
// that begins on a Sunday.
|
||||
for (let i = 0; i < 42; i++) {
|
||||
for (let i = 0; i < length; i++) {
|
||||
days.push(new Date(cell));
|
||||
cell.setDate(cell.getDate() + 1);
|
||||
}
|
||||
@@ -84,11 +138,55 @@
|
||||
);
|
||||
|
||||
const monthName = $derived(
|
||||
cursor.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' })
|
||||
cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' })
|
||||
);
|
||||
|
||||
function step(months) {
|
||||
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + months, 1);
|
||||
// "24. aug. – 30. aug. 2026", collapsed to one month name when the week does not
|
||||
// straddle two. The year is said once, at the end, where it is least in the way.
|
||||
const weekName = $derived.by(() => {
|
||||
const from = grid[0];
|
||||
const to = grid[grid.length - 1];
|
||||
const day = { day: 'numeric', month: 'short' };
|
||||
const left =
|
||||
from.getMonth() === to.getMonth()
|
||||
? from.toLocaleDateString(tag(), { day: 'numeric' })
|
||||
: from.toLocaleDateString(tag(), day);
|
||||
return `${left}–${to.toLocaleDateString(tag(), day)} ${to.getFullYear()}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* The ISO week number, which is what a Dane means by "uge 35".
|
||||
*
|
||||
* Counted from the Thursday of the week, because that is the definition: week 1
|
||||
* is the one containing the first Thursday of the year, so the Thursday is the
|
||||
* only day guaranteed to be in the same year as the week it belongs to.
|
||||
*/
|
||||
function isoWeek(d) {
|
||||
const thursday = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
thursday.setUTCDate(thursday.getUTCDate() - ((thursday.getUTCDay() + 6) % 7) + 3);
|
||||
const first = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4));
|
||||
first.setUTCDate(first.getUTCDate() - ((first.getUTCDay() + 6) % 7) + 3);
|
||||
return 1 + Math.round((thursday - first) / (7 * 86400000));
|
||||
}
|
||||
|
||||
const heading = $derived(span === 'week' ? weekName : monthName);
|
||||
const weekNumber = $derived(span === 'week' ? isoWeek(grid[0]) : null);
|
||||
|
||||
// A week shows seven cells where a month shows forty-two, so each one has the
|
||||
// height to be a list rather than a hint. Truncating it to three there would
|
||||
// hide work in the empty half of a tall cell.
|
||||
const chipLimit = $derived(span === 'week' ? 10 : 3);
|
||||
|
||||
// One step is one span: a month view pages by month, a week view by week. A week
|
||||
// view that paged by month would be back to the problem it exists to solve.
|
||||
function step(n) {
|
||||
if (span === 'week') {
|
||||
const next = new Date(cursor);
|
||||
next.setDate(next.getDate() + n * 7);
|
||||
cursor = next;
|
||||
return;
|
||||
}
|
||||
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + n, 1);
|
||||
}
|
||||
|
||||
// --- dropping a task on a day ---------------------------------------------------
|
||||
@@ -111,16 +209,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="calendar">
|
||||
<div class="calendar" class:week={span === 'week'}>
|
||||
<header>
|
||||
<button onclick={() => step(-1)} aria-label="Forrige måned">‹</button>
|
||||
<h2>{monthName}</h2>
|
||||
<button onclick={() => step(1)} aria-label="Næste måned">›</button>
|
||||
<button class="today" onclick={() => (cursor = startOfMonth(new Date()))}>I dag</button>
|
||||
<button onclick={() => step(-1)} aria-label={span === 'week' ? t('view.prevWeek') : t('view.prevMonth')}
|
||||
>‹</button
|
||||
>
|
||||
<h2 class:week={span === 'week'}>
|
||||
{heading}
|
||||
{#if weekNumber}<span class="weekno">{t('view.weekNumber', { n: weekNumber })}</span>{/if}
|
||||
</h2>
|
||||
<button onclick={() => step(1)} aria-label={span === 'week' ? t('view.nextWeek') : t('view.nextMonth')}
|
||||
>›</button
|
||||
>
|
||||
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
|
||||
</header>
|
||||
|
||||
<div class="weekdays" aria-hidden="true">
|
||||
{#each ['man', 'tir', 'ons', 'tor', 'fre', 'lør', 'søn'] as day}
|
||||
{#each WEEKDAYS as day}
|
||||
<span>{day}</span>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -128,7 +233,8 @@
|
||||
<div class="grid">
|
||||
{#each grid as date (date.toISOString())}
|
||||
{@const tasks = tasksOn(date)}
|
||||
{@const outside = date.getMonth() !== cursor.getMonth()}
|
||||
<!-- A week has no outside: every day in it is the week you asked for. -->
|
||||
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="day"
|
||||
@@ -140,9 +246,15 @@
|
||||
ondragleave={() => (over = null)}
|
||||
ondrop={(e) => onDrop(e, date)}
|
||||
>
|
||||
<!-- The weekday strip above the grid is enough on a wide screen. A week
|
||||
stacked into rows on a phone hides it, and a column of bare numbers
|
||||
is not a week — so the cell carries its own name there. -->
|
||||
{#if span === 'week'}
|
||||
<span class="weekday">{WEEKDAYS[(date.getDay() + 6) % 7]}</span>
|
||||
{/if}
|
||||
<span class="number">{date.getDate()}</span>
|
||||
|
||||
{#each tasks.slice(0, 3) as task (task.id)}
|
||||
{#each tasks.slice(0, chipLimit) as task (task.id)}
|
||||
<!-- Draggable as well as clickable: within a month, moving something
|
||||
to another day is the whole reason to be looking at a month. The
|
||||
browser needs a few pixels of movement before it calls a press a
|
||||
@@ -159,8 +271,8 @@
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if tasks.length > 3}
|
||||
<span class="more">+{tasks.length - 3} mere</span>
|
||||
{#if tasks.length > chipLimit}
|
||||
<span class="more">{t('view.more', { n: tasks.length - chipLimit })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -181,6 +293,24 @@
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* A date range is not a month name: "24.–30. aug. 2026" is already capitalised
|
||||
where it should be, and capitalising it again gives "24.–30. Aug. 2026". */
|
||||
h2.week {
|
||||
text-transform: none;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
.weekno {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 400;
|
||||
color: var(--ink-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
header button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -244,6 +374,22 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Seven cells instead of forty-two, so the height a month has to spend on six
|
||||
rows goes into one. It is what makes the week a view rather than only a wider
|
||||
drop target. */
|
||||
.calendar.week .day {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
/* Only shown when the grid has stacked; the strip above says it otherwise. */
|
||||
.weekday {
|
||||
display: none;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Days from the neighbouring months stay visible but recede — removing them
|
||||
would leave ragged holes at the corners of the grid. */
|
||||
.day.outside {
|
||||
@@ -311,9 +457,6 @@
|
||||
padding-left: var(--s1);
|
||||
}
|
||||
|
||||
/* On a phone a seven-column grid of 92px cells is unreadable. The cells shrink
|
||||
and the chips give way to a count — the month shape is the useful part at
|
||||
that width, not the titles. */
|
||||
@media (max-width: 620px) {
|
||||
.day {
|
||||
min-height: 56px;
|
||||
@@ -342,5 +485,59 @@
|
||||
.more {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* A week on a phone is not seven columns of 45px. It becomes seven rows —
|
||||
the same information in the shape the screen has room for, and each row
|
||||
is still a drop target, which is the whole point of the view.
|
||||
|
||||
The chips stay readable here, unlike in the month grid above: a
|
||||
full-width row has room for a title, and a week somebody opened on
|
||||
purpose is one they want to read rather than count. */
|
||||
.calendar.week .grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.calendar.week .day {
|
||||
min-height: 0;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--s2);
|
||||
padding: var(--s2);
|
||||
}
|
||||
|
||||
.calendar.week .weekdays {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar.week .weekday {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.calendar.week .chip {
|
||||
font-size: var(--text-xs);
|
||||
height: auto;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid var(--line-strong);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.calendar.week .chip[data-priority='1'] {
|
||||
border-left-color: var(--p1);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.calendar.week .chip[data-priority='2'] {
|
||||
border-left-color: var(--p2);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.calendar.week .chip[data-priority='3'] {
|
||||
border-left-color: var(--p3);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.calendar.week .more {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
/** Cmd+K: search across everything the user can see. */
|
||||
import { api } from '$lib/api.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { open = $bindable(false) } = $props();
|
||||
@@ -87,13 +88,13 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="scrim" onclick={() => (open = false)}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="palette" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Søg">
|
||||
<div class="palette" onclick={(e) => e.stopPropagation()} role="dialog" aria-label={t('nav.search')}>
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value={query}
|
||||
{onkeydown}
|
||||
placeholder="Søg i opgaver og projekter"
|
||||
aria-label="Søg"
|
||||
placeholder={t('nav.searchLong')}
|
||||
aria-label={t('nav.search')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
@@ -115,7 +116,7 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if query.trim()}
|
||||
<p class="empty">Ingen resultater.</p>
|
||||
<p class="empty">{t('nav.noResults')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
import { api } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
|
||||
|
||||
let text = $state('');
|
||||
@@ -144,15 +144,15 @@
|
||||
bind:value={text}
|
||||
{onkeydown}
|
||||
type="text"
|
||||
placeholder="Tilføj en opgave — prøv “betal moms i morgen kl 10 p1 #Firma”"
|
||||
aria-label="Ny opgave"
|
||||
placeholder={t('task.placeholder')}
|
||||
aria-label={t('task.new')}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if text.trim()}
|
||||
<button type="submit" class="submit" disabled={submitting}>Tilføj</button>
|
||||
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { api } from '$lib/api.js';
|
||||
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
@@ -18,7 +20,13 @@
|
||||
newName = '';
|
||||
adding = false;
|
||||
const project = await app.createProject(name);
|
||||
if (project) goto(`/projekt/${project.id}`);
|
||||
if (!project) return;
|
||||
// Closes the drawer as well as navigating. On a phone the sidebar is an
|
||||
// overlay, and creating a project moved the page underneath it while leaving
|
||||
// it covering the screen — every link in here closes it, and this makes the
|
||||
// same move for the same reason.
|
||||
onnavigate?.();
|
||||
goto(`/projekt/${project.id}`);
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
@@ -79,8 +87,6 @@
|
||||
let newGroupName = $state('');
|
||||
let renamingGroup = $state(null);
|
||||
let groupName = $state('');
|
||||
/** The group whose swatches are open, if any. */
|
||||
let coloringGroup = $state(null);
|
||||
|
||||
async function createGroup(event) {
|
||||
event.preventDefault();
|
||||
@@ -104,11 +110,9 @@
|
||||
// Says what happens to the projects, because "slet gruppen" reads like it
|
||||
// might take them with it. It does not: only the heading goes.
|
||||
const kept =
|
||||
count === 1
|
||||
? 'Projektet i den bliver, men ligger bagefter uden gruppe.'
|
||||
: `De ${count} projekter bliver, men ligger bagefter uden gruppe.`;
|
||||
if (!confirm(count ? `Slet gruppen "${group.name}"? ${kept}` : `Slet gruppen "${group.name}"?`))
|
||||
return;
|
||||
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
|
||||
const question = t('sidebar.deleteGroup', { name: group.name });
|
||||
if (!confirm(count ? `${question} ${kept}` : question)) return;
|
||||
await app.deleteGroup(group.id);
|
||||
}
|
||||
|
||||
@@ -224,6 +228,34 @@
|
||||
await app.reorderProjects(ordered.map((p) => p.id));
|
||||
}
|
||||
|
||||
// --- a task dropped on "I dag" ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* "Gør det i dag" is the most-made rescheduling there is, and the sidebar is
|
||||
* where the pointer already goes.
|
||||
*
|
||||
* Only this one of the two date views. "Kommende" is a range rather than a
|
||||
* day — it starts today and runs a week — so a drop on it would have to invent
|
||||
* a date the label does not name. The month grid inside it takes drops per
|
||||
* cell, which is where a specific day is actually offered.
|
||||
*/
|
||||
let overToday = $state(false);
|
||||
|
||||
function todayISO() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
now.getDate()
|
||||
).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function onDropOnToday(event) {
|
||||
event.preventDefault();
|
||||
const id = dragged(event, TASK);
|
||||
overToday = false;
|
||||
if (!id) return;
|
||||
await app.reschedule(id, todayISO());
|
||||
}
|
||||
|
||||
// --- a task dropped on a project ------------------------------------------------
|
||||
|
||||
/** The project row lit up because a task is hovering over it. */
|
||||
@@ -296,7 +328,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar" aria-label="Hovedmenu">
|
||||
<nav class="sidebar" aria-label={t('nav.main')}>
|
||||
<div class="brand">
|
||||
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
|
||||
starts with in the elder futhark. One glyph, no wordmark beside it —
|
||||
@@ -306,13 +338,26 @@
|
||||
</div>
|
||||
|
||||
<div class="views">
|
||||
<a href="/" class:active={current === '/'} onclick={onnavigate}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<a
|
||||
href="/"
|
||||
class:active={current === '/'}
|
||||
class:receiving={overToday}
|
||||
onclick={onnavigate}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, TASK)) return;
|
||||
accept(e);
|
||||
overToday = true;
|
||||
}}
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={onDropOnToday}
|
||||
>
|
||||
<span class="dot today" aria-hidden="true"></span>
|
||||
I dag
|
||||
{t('nav.today')}
|
||||
</a>
|
||||
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
Kommende
|
||||
{t('nav.upcoming')}
|
||||
</a>
|
||||
{#if app.projects.some((p) => p.shared)}
|
||||
<!-- Only where delegating is possible at all. On an instance with one
|
||||
@@ -320,9 +365,17 @@
|
||||
entry is a question the sidebar keeps asking and answering "no". -->
|
||||
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
Venter på andre
|
||||
{t('nav.delegated')}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- After the three views of what is left, because it is the one that is not
|
||||
about what is left. It earns a permanent place rather than a toggle in
|
||||
each view: somebody looking for a task they closed by mistake does not
|
||||
know which view they closed it in. -->
|
||||
<a href="/faerdige" class:active={current === '/faerdige'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('done.title')}
|
||||
</a>
|
||||
{#if app.inbox}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
{/if}
|
||||
@@ -364,6 +417,36 @@
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<!--
|
||||
A fixed heading that folds, the same way a project group's does.
|
||||
|
||||
One snippet for all four rather than four copies of a chevron and an aria
|
||||
attribute: they have to behave identically, and the group heading is already
|
||||
the proof that they drift otherwise — it grew a fold, a colour and two
|
||||
actions while these stayed plain text.
|
||||
|
||||
The whole heading is the target, not the chevron. At this size a lone chevron
|
||||
is something you miss, and the name is where the eye already is.
|
||||
-->
|
||||
{#snippet foldHeading(key, label)}
|
||||
<h2 class="fold">
|
||||
<button
|
||||
onclick={() => app.toggleSection(key)}
|
||||
aria-expanded={!app.sectionCollapsed(key)}
|
||||
>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:collapsed={app.sectionCollapsed(key)}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
{label}
|
||||
</button>
|
||||
</h2>
|
||||
{/snippet}
|
||||
|
||||
<div class="group">
|
||||
<!-- The heading is also the drop target for "no group": without it, a project
|
||||
dragged into a group could only be got out again by emptying the group,
|
||||
@@ -381,14 +464,14 @@
|
||||
ondragleave={() => (overGroup = null)}
|
||||
ondrop={(e) => onDropInGroup(e, '')}
|
||||
>
|
||||
<h2>Projekter</h2>
|
||||
{@render foldHeading('projects', t('sidebar.projects'))}
|
||||
<button
|
||||
class="icon"
|
||||
onclick={() => {
|
||||
adding = !adding;
|
||||
addingGroup = false;
|
||||
}}
|
||||
aria-label="Nyt projekt">+</button
|
||||
aria-label={t('sidebar.newProject')}>+</button
|
||||
>
|
||||
<button
|
||||
class="icon new-group"
|
||||
@@ -397,8 +480,8 @@
|
||||
adding = false;
|
||||
newGroupName = '';
|
||||
}}
|
||||
aria-label="Ny gruppe"
|
||||
title="Ny gruppe"
|
||||
aria-label={t('sidebar.newGroup')}
|
||||
title={t('sidebar.newGroup')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
@@ -408,12 +491,11 @@
|
||||
|
||||
{#if adding}
|
||||
<form onsubmit={createProject}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={newName}
|
||||
autofocus
|
||||
placeholder="Projektnavn"
|
||||
aria-label="Projektnavn"
|
||||
use:focusOnMount
|
||||
placeholder={t('sidebar.projectName')}
|
||||
aria-label={t('sidebar.projectName')}
|
||||
onblur={() => !newName.trim() && (adding = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
|
||||
/>
|
||||
@@ -422,24 +504,25 @@
|
||||
|
||||
{#if addingGroup}
|
||||
<form onsubmit={createGroup}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={newGroupName}
|
||||
autofocus
|
||||
placeholder="Gruppens navn"
|
||||
aria-label="Gruppens navn"
|
||||
use:focusOnMount
|
||||
placeholder={t('sidebar.groupName')}
|
||||
aria-label={t('sidebar.groupName')}
|
||||
onblur={() => !newGroupName.trim() && (addingGroup = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
|
||||
/>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#each ungrouped as project (project.id)}
|
||||
{@render projectRow(project, true)}
|
||||
{/each}
|
||||
{#if !app.sectionCollapsed('projects')}
|
||||
{#each ungrouped as project (project.id)}
|
||||
{@render projectRow(project, true)}
|
||||
{/each}
|
||||
|
||||
{#if own.length === 0 && !adding}
|
||||
<p class="empty">Ingen projekter endnu.</p>
|
||||
{#if own.length === 0 && !adding}
|
||||
<p class="empty">{t('sidebar.noProjects')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -466,15 +549,46 @@
|
||||
ondrop={(e) => onGroupDrop(e, group)}
|
||||
>
|
||||
{#if renamingGroup === group.id}
|
||||
<form onsubmit={(e) => renameGroup(e, group)}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<!-- Renaming is where the colour is chosen too.
|
||||
The dot used to be a button in the row, which made the heading
|
||||
carry four controls — dot, name, Omdøb, Slet — on a line narrow
|
||||
enough that they crowded the name. Both are edits to the same
|
||||
thing, so both belong in the same moment: you open the group to
|
||||
change it, and change what you came to change.
|
||||
|
||||
`focusout` rather than `blur` closes it, and only when focus has
|
||||
left the form altogether — clicking a swatch is still inside it.
|
||||
The swatches also refuse focus on mousedown, because Safari and
|
||||
Firefox do not focus a button on click, so `relatedTarget` would
|
||||
be null and the form would shut under the pointer. -->
|
||||
<form
|
||||
class="renaming"
|
||||
onsubmit={(e) => renameGroup(e, group)}
|
||||
onfocusout={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null;
|
||||
}}
|
||||
>
|
||||
<input
|
||||
bind:value={groupName}
|
||||
autofocus
|
||||
aria-label="Gruppens navn"
|
||||
onblur={() => (renamingGroup = null)}
|
||||
use:focusOnMount
|
||||
aria-label={t('sidebar.groupName')}
|
||||
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
|
||||
/>
|
||||
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
|
||||
{#each COLORS as color (color.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="swatch"
|
||||
class:chosen={(group.color ?? 'graphite') === color.id}
|
||||
style="background: {colorVar(color.id)}"
|
||||
title={t(color.name)}
|
||||
aria-label={t(color.name)}
|
||||
aria-pressed={(group.color ?? 'graphite') === color.id}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => app.setGroupColor(group.id, color.id)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- The button sits inside the heading, not around it, the same way a
|
||||
@@ -483,32 +597,35 @@
|
||||
the heading away from a screen reader. The whole heading folds —
|
||||
at this size a lone chevron is a target you miss, and the name is
|
||||
where the eye already is. -->
|
||||
<!-- The dot is the control as well as the preview: what you press is
|
||||
the thing you are about to change. It also keeps the heading's
|
||||
actions down to two — they carry `opacity: 0` rather than
|
||||
`display: none`, so they take their width whether or not you can
|
||||
see them, and a third one squeezed the group's name until it
|
||||
truncated. -->
|
||||
<!-- The chevron folds; the name opens the group's own page. Two
|
||||
targets in one heading, because they are two different intentions
|
||||
and the old single one could only ever serve the smaller. -->
|
||||
<button
|
||||
class="dot group-dot"
|
||||
style="background: {colorVar(group.color)}"
|
||||
onclick={() => (coloringGroup = coloringGroup === group.id ? null : group.id)}
|
||||
aria-expanded={coloringGroup === group.id}
|
||||
aria-label="Farve på {group.name}"
|
||||
title="Farve"
|
||||
></button>
|
||||
class="chevron-button"
|
||||
onclick={() => app.toggleGroup(group.id)}
|
||||
aria-expanded={!group.collapsed}
|
||||
aria-label={group.collapsed
|
||||
? t('sidebar.unfold', { name: group.name })
|
||||
: t('sidebar.fold', { name: group.name })}
|
||||
>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:collapsed={group.collapsed}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 class="fold">
|
||||
<button onclick={() => app.toggleGroup(group.id)} aria-expanded={!group.collapsed}>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:collapsed={group.collapsed}
|
||||
viewBox="0 0 24 24"
|
||||
<a href="/gruppe/{group.id}" onclick={onnavigate}>
|
||||
<span
|
||||
class="dot group-dot"
|
||||
style="background: {colorVar(group.color)}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
></span>
|
||||
{group.name}
|
||||
</button>
|
||||
</a>
|
||||
</h2>
|
||||
<span class="count">{inside.length}</span>
|
||||
<button
|
||||
@@ -516,37 +633,18 @@
|
||||
onclick={() => {
|
||||
renamingGroup = group.id;
|
||||
groupName = group.name;
|
||||
}}>Omdøb</button
|
||||
}}>{t('sidebar.rename')}</button
|
||||
>
|
||||
<button class="group-action remove" onclick={() => removeGroup(group)}>Slet</button>
|
||||
<button class="group-action remove" onclick={() => removeGroup(group)}>{t('sidebar.delete')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if coloringGroup === group.id}
|
||||
<div class="swatches" role="group" aria-label="Farve på {group.name}">
|
||||
{#each COLORS as color (color.id)}
|
||||
<button
|
||||
class="swatch"
|
||||
class:chosen={(group.color ?? 'graphite') === color.id}
|
||||
style="background: {colorVar(color.id)}"
|
||||
title={color.name}
|
||||
aria-label={color.name}
|
||||
aria-pressed={(group.color ?? 'graphite') === color.id}
|
||||
onclick={() => {
|
||||
app.setGroupColor(group.id, color.id);
|
||||
coloringGroup = null;
|
||||
}}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !group.collapsed}
|
||||
{#each inside as project (project.id)}
|
||||
{@render projectRow(project, true)}
|
||||
{/each}
|
||||
{#if !inside.length}
|
||||
<p class="empty">Tom — træk et projekt herop.</p>
|
||||
<p class="empty">{t('sidebar.emptyGroup')}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -554,43 +652,49 @@
|
||||
|
||||
{#if shared.length}
|
||||
<div class="group">
|
||||
<div class="group-head"><h2>Delt med mig</h2></div>
|
||||
{#each shared as project (project.id)}
|
||||
{@render projectRow(project, false)}
|
||||
{/each}
|
||||
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
|
||||
{#if !app.sectionCollapsed('shared')}
|
||||
{#each shared as project (project.id)}
|
||||
{@render projectRow(project, false)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filters.length}
|
||||
<div class="group">
|
||||
<div class="group-head"><h2>Filtre</h2></div>
|
||||
{#each filters as filter (filter.id)}
|
||||
<a
|
||||
href="/filter/{filter.id}"
|
||||
class:active={current === `/filter/${filter.id}`}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{filter.name}
|
||||
</a>
|
||||
{/each}
|
||||
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
|
||||
{#if !app.sectionCollapsed('filters')}
|
||||
{#each filters as filter (filter.id)}
|
||||
<a
|
||||
href="/filter/{filter.id}"
|
||||
class:active={current === `/filter/${filter.id}`}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{filter.name}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if labels.length}
|
||||
<div class="group">
|
||||
<div class="group-head"><h2>Etiketter</h2></div>
|
||||
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
|
||||
<a
|
||||
href="/etiket/{encodeURIComponent(label.name)}"
|
||||
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{label.name}
|
||||
<span class="count">{label.task_count}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
|
||||
{#if !app.sectionCollapsed('labels')}
|
||||
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
|
||||
<a
|
||||
href="/etiket/{encodeURIComponent(label.name)}"
|
||||
class:active={current === `/etiket/${encodeURIComponent(label.name)}`}
|
||||
onclick={onnavigate}
|
||||
>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{label.name}
|
||||
<span class="count">{label.task_count}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -607,20 +711,20 @@
|
||||
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
Indstillinger
|
||||
{t('nav.settings')}
|
||||
</a>
|
||||
|
||||
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
|
||||
normal, and the app keeps working without it. -->
|
||||
{#if !app.connected}
|
||||
<span class="offline" title="Ændringer fra andre vises ikke lige nu">Offline</span>
|
||||
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
|
||||
{/if}
|
||||
<button class="user" onclick={signOut}>
|
||||
<span class="avatar" style="background: {app.user?.avatar_color}">
|
||||
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
<span class="user-name">{app.user?.name}</span>
|
||||
<span class="signout">Log ud</span>
|
||||
<span class="signout">{t('nav.signOut')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
|
||||
@@ -631,7 +735,7 @@
|
||||
class:resizing
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Bredde på sidebjælken"
|
||||
aria-label={t('nav.sidebarWidth')}
|
||||
tabindex="0"
|
||||
onpointerdown={startResize}
|
||||
onpointermove={onResize}
|
||||
@@ -663,13 +767,20 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sits on the border, wider than it looks: a 1px target is a target you miss.
|
||||
The visible line stays the border; this only widens what the pointer hits. */
|
||||
/* Wider than it looks: a 1px target is a target you miss. The visible line stays
|
||||
the border; this only widens what the pointer hits.
|
||||
|
||||
Flush with the inside edge rather than straddling the border. It used to sit
|
||||
at `right: -3px`, and an absolutely positioned child still counts towards its
|
||||
containing block's scrollable overflow — so those three pixels gave the whole
|
||||
sidebar a horizontal scrollbar on every desktop, always, whatever was in it.
|
||||
Seven pixels inside the edge is the same target for anybody aiming at the
|
||||
border, and it is a target that is actually inside the box it belongs to. */
|
||||
.resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -3px;
|
||||
right: 0;
|
||||
width: 7px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
@@ -825,6 +936,25 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The chevron on its own, so the name beside it can be a link to the group's
|
||||
page. Sized as a target rather than as a glyph: 11px of arrow is something
|
||||
you miss. */
|
||||
.chevron-button {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chevron-button:hover {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.fold a,
|
||||
.fold button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -840,10 +970,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fold a:hover,
|
||||
.fold button:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.fold a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
@@ -888,21 +1023,12 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* The group's own mark, in front of the chevron so the colours of the headings
|
||||
line up with each other rather than with the projects underneath. */
|
||||
/* The group's own mark, between the chevron and the name — inside the fold
|
||||
button, so it cannot push the heading in. It is a mark now and not a
|
||||
control: the colour is chosen while renaming, which is the other edit to
|
||||
the same thing. */
|
||||
.group-dot {
|
||||
margin-left: var(--s2);
|
||||
padding: 0;
|
||||
box-shadow: 0 0 0 2px var(--surface-sunken);
|
||||
transition: box-shadow var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.group-dot:hover,
|
||||
.group-dot:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--surface-sunken),
|
||||
0 0 0 3px var(--line-strong);
|
||||
outline: none;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Projects inside a group are indented to the group's name, not to its dot:
|
||||
@@ -922,11 +1048,20 @@
|
||||
padding-left: calc(var(--s2) + var(--s4));
|
||||
}
|
||||
|
||||
/* Inside the rename form now, so it needs no padding of its own — the form
|
||||
already sits where the heading did. */
|
||||
.renaming {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s2);
|
||||
padding: var(--s2) var(--s2) var(--s3);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
@@ -1106,6 +1241,7 @@
|
||||
.avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
border-radius: var(--radius-full);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -1115,7 +1251,18 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* `min-width: 0`, or the ellipsis never happens.
|
||||
A flex item's default is `min-width: auto`, which refuses to shrink below its
|
||||
content — so `overflow: hidden` had nothing to hide and the row simply grew
|
||||
wider than the sidebar. And the sidebar has `overflow-y: auto`, which CSS
|
||||
resolves to `auto` on the other axis too, so the result was a horizontal
|
||||
scrollbar under the whole menu on a perfectly ordinary desktop.
|
||||
|
||||
Not fixed with `overflow-x: hidden` on the sidebar: the resize handle sits at
|
||||
`right: -3px` and would be clipped away. The row shrinking is the fix; the
|
||||
test asserting the sidebar never scrolls sideways is the backstop. */
|
||||
.user-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -1123,6 +1270,7 @@
|
||||
|
||||
.signout {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
opacity: 0;
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
||||
let mode = $state('loading');
|
||||
@@ -143,69 +145,68 @@
|
||||
{#if mode === 'loading'}
|
||||
<p class="lede"> </p>
|
||||
{:else if mode === 'setup'}
|
||||
<p class="lede">Opret den første konto. Den bliver administrator.</p>
|
||||
<p class="lede">{t('auth.firstAccount')}</p>
|
||||
|
||||
<label>
|
||||
Navn
|
||||
{t('auth.name')}
|
||||
<input bind:value={name} autocomplete="name" required />
|
||||
{#if fields.name}<span class="field-error">Skal udfyldes</span>{/if}
|
||||
{#if fields.name}<span class="field-error">{t('auth.nameRequired')}</span>{/if}
|
||||
</label>
|
||||
<label>
|
||||
E-mail
|
||||
{t('auth.email')}
|
||||
<input bind:value={email} type="email" autocomplete="username" required />
|
||||
{#if fields.email}<span class="field-error">Skal være en e-mailadresse</span>{/if}
|
||||
{#if fields.email}<span class="field-error">{t('auth.emailInvalid')}</span>{/if}
|
||||
</label>
|
||||
<label>
|
||||
Adgangskode
|
||||
{t('auth.password')}
|
||||
<input bind:value={password} type="password" autocomplete="new-password" required />
|
||||
<span class="hint">Mindst 10 tegn. En sætning er både nemmere og stærkere.</span>
|
||||
<span class="hint">{t('auth.passwordHint')}</span>
|
||||
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Opret konto</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.createAccount')}</button>
|
||||
{:else if mode === 'totp'}
|
||||
<p class="lede">Indtast koden fra din authenticator.</p>
|
||||
<p class="lede">{t('auth.totpPrompt')}</p>
|
||||
|
||||
<label>
|
||||
Kode
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
{t('auth.code')}
|
||||
<input
|
||||
bind:value={code}
|
||||
autofocus
|
||||
use:focusOnMount
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="123456"
|
||||
class="code"
|
||||
required
|
||||
/>
|
||||
<span class="hint">Har du mistet din telefon, virker en af dine gendannelseskoder her.</span>
|
||||
<span class="hint">{t('auth.totpRecovery')}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Fortsæt</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.continue')}</button>
|
||||
{:else if mode === 'forgot'}
|
||||
<p class="lede">Vi sender et link til at vælge en ny adgangskode.</p>
|
||||
<p class="lede">{t('auth.resetPrompt')}</p>
|
||||
|
||||
<label>
|
||||
E-mail
|
||||
{t('auth.email')}
|
||||
<input bind:value={email} type="email" autocomplete="username" required />
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Send link</button>
|
||||
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.sendLink')}</button>
|
||||
<button type="button" class="link" onclick={() => (mode = 'login')}>{t('auth.back')}</button>
|
||||
{:else if mode === 'invite' || mode === 'reset'}
|
||||
{#if !token}
|
||||
<!-- A link that lost its token on the way. Saying so beats a form that
|
||||
cannot succeed and a message that arrives only after it is filled in. -->
|
||||
<p class="lede">
|
||||
Linket er ikke komplet. Åbn det fra e-mailen igen, eller bed om et nyt.
|
||||
{t('auth.linkBroken')}
|
||||
</p>
|
||||
<a class="link" href="/">Til log ind</a>
|
||||
<a class="link" href="/">{t('auth.toSignIn')}</a>
|
||||
{:else if app.user}
|
||||
<!-- Already somebody else. The invite is tied to an address, so using it
|
||||
from this session would create a second account for a person who is
|
||||
right here in the first one. -->
|
||||
<p class="lede">
|
||||
Du er logget ind som {app.user.name}. Log ud først, og åbn så linket igen.
|
||||
{t('auth.alreadySignedIn', { name: app.user.name })}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -213,61 +214,61 @@
|
||||
onclick={async () => {
|
||||
await api.logout();
|
||||
location.reload();
|
||||
}}>Log ud</button
|
||||
}}>{t('auth.signOut')}</button
|
||||
>
|
||||
{:else if mode === 'invite'}
|
||||
<p class="lede">Du er inviteret. Vælg et navn og en adgangskode.</p>
|
||||
<p class="lede">{t('auth.invited')}</p>
|
||||
|
||||
<label>
|
||||
Navn
|
||||
{t('auth.name')}
|
||||
<input bind:value={name} autocomplete="name" required />
|
||||
{#if fields.name}<span class="field-error">Skal udfyldes</span>{/if}
|
||||
{#if fields.name}<span class="field-error">{t('auth.nameRequired')}</span>{/if}
|
||||
</label>
|
||||
<label>
|
||||
Adgangskode
|
||||
{t('auth.password')}
|
||||
<input bind:value={password} type="password" autocomplete="new-password" required />
|
||||
<span class="hint">Mindst 10 tegn. En sætning er både nemmere og stærkere.</span>
|
||||
<span class="hint">{t('auth.passwordHint')}</span>
|
||||
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Opret konto</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.createAccount')}</button>
|
||||
{:else}
|
||||
<p class="lede">Vælg en ny adgangskode.</p>
|
||||
<p class="lede">{t('auth.pickNew')}</p>
|
||||
|
||||
<label>
|
||||
Ny adgangskode
|
||||
{t('auth.newPassword')}
|
||||
<input bind:value={password} type="password" autocomplete="new-password" required />
|
||||
<span class="hint">Mindst 10 tegn. Alle andre enheder bliver logget ud.</span>
|
||||
<span class="hint">{t('auth.newPasswordHint')}</span>
|
||||
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Gem adgangskoden</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.savePassword')}</button>
|
||||
{/if}
|
||||
{:else if mode === 'done'}
|
||||
<p class="lede">Adgangskoden er skiftet. Log ind med den nye.</p>
|
||||
<a class="link" href="/">Til log ind</a>
|
||||
<p class="lede">{t('auth.passwordChanged')}</p>
|
||||
<a class="link" href="/">{t('auth.toSignIn')}</a>
|
||||
{:else if mode === 'sent'}
|
||||
<!-- Deliberately not "we sent an email": that would confirm the address
|
||||
has an account here to anybody who tried it. -->
|
||||
<p class="lede">
|
||||
Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.
|
||||
{t('auth.resetSent')}
|
||||
</p>
|
||||
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</button>
|
||||
<button type="button" class="link" onclick={() => (mode = 'login')}>{t('auth.back')}</button>
|
||||
{:else}
|
||||
<p class="lede">Log ind for at fortsætte.</p>
|
||||
<p class="lede">{t('auth.signInPrompt')}</p>
|
||||
|
||||
<label>
|
||||
E-mail
|
||||
{t('auth.email')}
|
||||
<input bind:value={email} type="email" autocomplete="username" required />
|
||||
</label>
|
||||
<label>
|
||||
Adgangskode
|
||||
{t('auth.password')}
|
||||
<input bind:value={password} type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Log ind</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.signIn')}</button>
|
||||
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
|
||||
Glemt adgangskode?
|
||||
{t('auth.forgot')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
|
||||
@@ -34,8 +36,8 @@
|
||||
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
|
||||
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
|
||||
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
|
||||
{ rule: 'FREQ=MONTHLY', label: 'Hver måned' },
|
||||
{ rule: 'FREQ=YEARLY', label: 'Hvert år' }
|
||||
{ rule: 'FREQ=MONTHLY', label: t('detail.monthly') },
|
||||
{ rule: 'FREQ=YEARLY', label: t('detail.yearly') }
|
||||
];
|
||||
|
||||
let subtasks = $state([]);
|
||||
@@ -96,8 +98,21 @@
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
/** Sends a patch through the store, so the row behind the drawer updates too. */
|
||||
/**
|
||||
* Sends a patch through the store, so the row behind the drawer updates too.
|
||||
*
|
||||
* `task` can be null by the time this runs. The three fields below save on
|
||||
* blur, and closing the drawer *is* a blur — the input is being unmounted, and
|
||||
* the browser fires blur on the way out. If the task left `app.tasks` in the
|
||||
* same moment (it was completed, it was moved, another browser deleted it),
|
||||
* `app.detailTask` is already null and this is a handler for a task that is no
|
||||
* longer there.
|
||||
*
|
||||
* It threw `Cannot read properties of null` and stopped the page. Nothing to
|
||||
* save is not an error; it is the ordinary end of an edit nobody made.
|
||||
*/
|
||||
async function save(patch) {
|
||||
if (!task) return;
|
||||
await app.update(task.id, patch);
|
||||
}
|
||||
|
||||
@@ -114,6 +129,7 @@
|
||||
* worse than one that does nothing.
|
||||
*/
|
||||
async function saveContent() {
|
||||
if (!task) return;
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || trimmed === task.content) {
|
||||
content = task.content;
|
||||
@@ -161,7 +177,7 @@
|
||||
if (patch.priority) priority = patch.priority;
|
||||
if (patch.recurrence_rule) recurrence = patch.recurrence_rule;
|
||||
if (patch.labels) labels = patch.labels.join(', ');
|
||||
app.toast(`Læst som ${took.join(', ')}.`);
|
||||
app.toast(t('detail.readAs', { what: took.join(', ') }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,11 +197,12 @@
|
||||
}
|
||||
|
||||
function saveDescription() {
|
||||
if (description === (task.description ?? '')) return;
|
||||
if (!task || description === (task.description ?? '')) return;
|
||||
save({ description });
|
||||
}
|
||||
|
||||
function saveLabels() {
|
||||
if (!task) return;
|
||||
const list = labels
|
||||
.split(',')
|
||||
.map((l) => l.trim())
|
||||
@@ -347,9 +364,9 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<div class="scrim" onclick={onclose} role="presentation"></div>
|
||||
|
||||
<aside class="drawer" aria-label="Opgave">
|
||||
<aside class="drawer" aria-label={t('detail.task')}>
|
||||
<header>
|
||||
<button class="close" onclick={onclose} aria-label="Luk">
|
||||
<button class="close" onclick={onclose} aria-label={t('detail.close')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
@@ -358,43 +375,43 @@
|
||||
|
||||
<div class="scroll">
|
||||
<div class="field">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
class="title"
|
||||
use:focusOnMount
|
||||
bind:value={content}
|
||||
onblur={saveContent}
|
||||
aria-label="Opgavens tekst"
|
||||
aria-label={t('detail.text')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="description">Beskrivelse</label>
|
||||
<label for="description">{t('detail.description')}</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows="4"
|
||||
bind:value={description}
|
||||
onblur={saveDescription}
|
||||
placeholder="Detaljer, links, hvad der skal huskes"
|
||||
placeholder={t('detail.descriptionPlaceholder')}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="priority">Prioritet</label>
|
||||
<label for="priority">{t('detail.priority')}</label>
|
||||
<select
|
||||
id="priority"
|
||||
bind:value={priority}
|
||||
onchange={() => save({ priority: Number(priority) })}
|
||||
>
|
||||
<option value={1}>P1 — haster</option>
|
||||
<option value={1}>{t('detail.p1')}</option>
|
||||
<option value={2}>P2</option>
|
||||
<option value={3}>P3</option>
|
||||
<option value={4}>Ingen</option>
|
||||
<option value={4}>{t('detail.none')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="due">Forfalder</label>
|
||||
<label for="due">{t('detail.due')}</label>
|
||||
<input
|
||||
id="due"
|
||||
type="date"
|
||||
@@ -405,18 +422,18 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="labels">Etiketter</label>
|
||||
<label for="labels">{t('detail.labels')}</label>
|
||||
<input
|
||||
id="labels"
|
||||
bind:value={labels}
|
||||
onblur={saveLabels}
|
||||
placeholder="adskilt af komma"
|
||||
placeholder={t('detail.labelsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="project">Projekt</label>
|
||||
<label for="project">{t('detail.project')}</label>
|
||||
<select id="project" bind:value={projectId} onchange={saveProject}>
|
||||
{#each app.projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
@@ -425,7 +442,7 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="repeat">Gentages</label>
|
||||
<label for="repeat">{t('detail.repeats')}</label>
|
||||
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
||||
{#each REPEATS as option (option.rule)}
|
||||
<option value={option.rule}>{option.label}</option>
|
||||
@@ -442,9 +459,9 @@
|
||||
|
||||
{#if members.length > 1}
|
||||
<div class="field">
|
||||
<label for="assignee">Ansvarlig</label>
|
||||
<label for="assignee">{t('detail.assignee')}</label>
|
||||
<select id="assignee" bind:value={assignee} onchange={saveAssignee}>
|
||||
<option value="">Ingen</option>
|
||||
<option value="">{t('detail.none')}</option>
|
||||
{#each members as member (member.user_id)}
|
||||
<option value={member.user_id}>{member.name}</option>
|
||||
{/each}
|
||||
@@ -453,7 +470,7 @@
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h3>Undertasks</h3>
|
||||
<h3>{t('detail.subtasks')}</h3>
|
||||
|
||||
{#each subtasks as subtask (subtask.id)}
|
||||
<div class="subtask" class:done={subtask.completed}>
|
||||
@@ -461,26 +478,26 @@
|
||||
class="check"
|
||||
class:checked={subtask.completed}
|
||||
onclick={() => toggleSubtask(subtask)}
|
||||
aria-label={subtask.completed ? 'Genåbn' : 'Markér som færdig'}
|
||||
aria-label={subtask.completed ? t('detail.reopen') : t('task.complete')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 12.5l4 4 8-8.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="subtask-text">{subtask.content}</span>
|
||||
<button class="remove" onclick={() => removeSubtask(subtask)} aria-label="Slet">
|
||||
<button class="remove" onclick={() => removeSubtask(subtask)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<form onsubmit={addSubtask}>
|
||||
<input bind:value={newSubtask} placeholder="Tilføj en undertask" aria-label="Ny undertask" />
|
||||
<input bind:value={newSubtask} placeholder={t('detail.addSubtask')} aria-label={t('detail.newSubtask')} />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Filer</h3>
|
||||
<h3>{t('detail.files')}</h3>
|
||||
|
||||
{#each attachments as attachment (attachment.id)}
|
||||
<div class="file">
|
||||
@@ -488,7 +505,7 @@
|
||||
in place would run its own script on this origin. -->
|
||||
<a href={attachment.url} download={attachment.filename}>{attachment.filename}</a>
|
||||
<span class="size">{kb(attachment.size)}</span>
|
||||
<button class="remove" onclick={() => removeAttachment(attachment)} aria-label="Slet">
|
||||
<button class="remove" onclick={() => removeAttachment(attachment)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -496,18 +513,18 @@
|
||||
|
||||
<label class="upload">
|
||||
<input type="file" onchange={upload} disabled={uploading} />
|
||||
<span>{uploading ? 'Lægger op …' : 'Vedhæft en fil'}</span>
|
||||
<span>{uploading ? t('detail.uploading') : t('detail.attachFile')}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Påmindelser</h3>
|
||||
<h3>{t('detail.reminders')}</h3>
|
||||
|
||||
{#each reminders as reminder (reminder.id)}
|
||||
<div class="reminder">
|
||||
<span>{reminder.remind_at ? stamp(reminder.remind_at) : `${reminder.offset_min} min.`}</span>
|
||||
{#if reminder.sent}<span class="sent">sendt</span>{/if}
|
||||
<button class="remove" onclick={() => removeReminder(reminder)} aria-label="Slet">
|
||||
{#if reminder.sent}<span class="sent">{t('detail.sent')}</span>{/if}
|
||||
<button class="remove" onclick={() => removeReminder(reminder)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -517,14 +534,14 @@
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={newReminder}
|
||||
aria-label="Ny påmindelse"
|
||||
aria-label={t('detail.newReminder')}
|
||||
/>
|
||||
<button class="add" type="submit">Tilføj</button>
|
||||
<button class="add" type="submit">{t('detail.add')}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Kommentarer</h3>
|
||||
<h3>{t('detail.comments')}</h3>
|
||||
|
||||
{#each comments as comment (comment.id)}
|
||||
<article class="comment">
|
||||
@@ -544,7 +561,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{#if comment.user_id === app.user?.id}
|
||||
<button class="remove" onclick={() => removeComment(comment)} aria-label="Slet">
|
||||
<button class="remove" onclick={() => removeComment(comment)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
@@ -555,10 +572,10 @@
|
||||
<textarea
|
||||
rows="2"
|
||||
bind:value={newComment}
|
||||
placeholder="Skriv en kommentar"
|
||||
aria-label="Ny kommentar"
|
||||
placeholder={t('detail.writeComment')}
|
||||
aria-label={t('detail.newComment')}
|
||||
></textarea>
|
||||
<button class="add" type="submit" disabled={posting}>Skriv</button>
|
||||
<button class="add" type="submit" disabled={posting}>{t('detail.post')}</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@@ -575,7 +592,12 @@
|
||||
.drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(480px, 100vw);
|
||||
/* `100%` rather than `100vw`. For a fixed element a percentage resolves
|
||||
against the initial containing block, which excludes the scrollbar gutter
|
||||
— `100vw` does not, so the drawer was a few pixels wider than the page and
|
||||
the whole document could be pushed sideways. */
|
||||
width: min(480px, 100%);
|
||||
max-width: 100%;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -625,7 +647,12 @@
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
/* Nothing inside may widen the drawer. A pasted URL in a comment or a long
|
||||
unbroken word would otherwise stretch the flex column and take the page
|
||||
with it. */
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--s4) var(--s4) var(--s8);
|
||||
display: flex;
|
||||
@@ -883,8 +910,12 @@
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
/* Full width, stated as four insets rather than a width: the box is then
|
||||
defined by the viewport's edges and cannot be a rounding error wider than
|
||||
the thing it is pinned to. */
|
||||
.drawer {
|
||||
width: 100vw;
|
||||
inset: 0;
|
||||
width: auto;
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* drawn where the row would land.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
|
||||
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import TaskRow from './TaskRow.svelte';
|
||||
|
||||
let { tasks, projectId, sectionId = '', canEdit = true } = $props();
|
||||
@@ -33,7 +34,12 @@
|
||||
}
|
||||
|
||||
function onDragOver(event, task) {
|
||||
if (!canEdit || !carries(event, TASK) || !draggingId || draggingId === task.id) return;
|
||||
// `carries` rather than `draggingId`: a task dragged from another list — the
|
||||
// unsectioned rows above, or another section — never called this component's
|
||||
// own dragstart, so its `draggingId` is null. Refusing on that made every
|
||||
// list reject everything that came from outside it, which is the whole point
|
||||
// of dragging between them.
|
||||
if (!canEdit || !carries(event, TASK) || draggingId === task.id) return;
|
||||
accept(event);
|
||||
|
||||
// Which half of the row the pointer is in decides the gap. Comparing against
|
||||
@@ -54,13 +60,22 @@
|
||||
// gesture and for sections with no rows to aim at. A drop that landed on a
|
||||
// row has already said exactly where it goes, so it stops here.
|
||||
event.stopPropagation();
|
||||
const id = draggingId;
|
||||
// From the drag itself, not from `draggingId`, which is only set when the
|
||||
// drag *started* in this list. A task dragged in from the unsectioned rows
|
||||
// or another section left it null, so this returned early — and it had
|
||||
// already called stopPropagation, so the section around it never got the
|
||||
// drop either. The result: a task could only be dropped into a section with
|
||||
// no rows in it to aim at, which made every section look like it could hold
|
||||
// exactly one task.
|
||||
const id = dragged(event, TASK) || draggingId;
|
||||
const below = overBelow;
|
||||
draggingId = null;
|
||||
overId = null;
|
||||
if (!id || !canEdit || id === target.id) return;
|
||||
|
||||
const ordered = [...tasks].sort((a, b) => a.sort_order - b.sort_order);
|
||||
// The dragged task may not be in this list at all — that is the cross-list
|
||||
// case — so `without` is "this list minus it" either way.
|
||||
const without = ordered.filter((t) => t.id !== id);
|
||||
const at = without.findIndex((t) => t.id === target.id);
|
||||
if (at < 0) return;
|
||||
@@ -101,7 +116,7 @@
|
||||
);
|
||||
} catch {
|
||||
app.replace(id, previous);
|
||||
app.toast('Kunne ikke flytte opgaven.');
|
||||
app.toast(t('task.moveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
* feels like a mis-click rather than an accomplishment.
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
|
||||
let { task, onedit } = $props();
|
||||
|
||||
let leaving = $state(false);
|
||||
|
||||
const PRIORITY_LABEL = { 1: 'Prioritet 1', 2: 'Prioritet 2', 3: 'Prioritet 3', 4: 'Ingen prioritet' };
|
||||
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
|
||||
|
||||
async function toggle() {
|
||||
if (task.completed) {
|
||||
@@ -33,17 +34,17 @@
|
||||
const due = new Date(date + 'T00:00:00');
|
||||
const days = Math.round((due - new Date(today.toDateString())) / 86400000);
|
||||
|
||||
if (days === 0) return { text: 'I dag', tone: 'today' };
|
||||
if (days === 1) return { text: 'I morgen', tone: 'soon' };
|
||||
if (days === -1) return { text: 'I går', tone: 'overdue' };
|
||||
if (days < 0) return { text: `${Math.abs(days)} dage forsinket`, tone: 'overdue' };
|
||||
if (days === 0) return { text: t('task.today'), tone: 'today' };
|
||||
if (days === 1) return { text: t('task.tomorrow'), tone: 'soon' };
|
||||
if (days === -1) return { text: t('task.yesterday'), tone: 'overdue' };
|
||||
if (days < 0) return { text: t('task.overdue', { n: Math.abs(days) }), tone: 'overdue' };
|
||||
if (days < 7)
|
||||
return {
|
||||
text: due.toLocaleDateString('da-DK', { weekday: 'long' }),
|
||||
text: due.toLocaleDateString(tag(), { weekday: 'long' }),
|
||||
tone: 'soon'
|
||||
};
|
||||
return {
|
||||
text: due.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' }),
|
||||
text: due.toLocaleDateString(tag(), { day: 'numeric', month: 'short' }),
|
||||
tone: 'later'
|
||||
};
|
||||
}
|
||||
@@ -53,7 +54,7 @@
|
||||
let due = $derived(dueLabel(task.due_date));
|
||||
let time = $derived(
|
||||
task.due_datetime
|
||||
? new Date(task.due_datetime).toLocaleTimeString('da-DK', {
|
||||
? new Date(task.due_datetime).toLocaleTimeString(tag(), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
@@ -66,8 +67,8 @@
|
||||
class="check"
|
||||
class:checked={task.completed || leaving}
|
||||
onclick={toggle}
|
||||
aria-label={task.completed ? 'Genåbn opgave' : 'Markér som færdig'}
|
||||
title={PRIORITY_LABEL[task.priority]}
|
||||
aria-label={task.completed ? t('task.reopen') : t('task.complete')}
|
||||
title={priorityLabel(task.priority)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path class="tick" d="M6 12.5l4 4 8-8.5" />
|
||||
@@ -83,13 +84,13 @@
|
||||
<span class="description">{task.description}</span>
|
||||
{/if}
|
||||
|
||||
{#if assignee || due || task.labels?.length || task.recurrence_text}
|
||||
{#if assignee || due || task.labels?.length || task.recurrence_text || task.subtask_count || task.attachment_count}
|
||||
<span class="meta">
|
||||
{#if assignee}
|
||||
<!-- First in the row, because "who" changes what the rest of the line
|
||||
means: a date on somebody else's task is their deadline, not
|
||||
yours. -->
|
||||
<span class="assignee" title="Ansvarlig: {assignee.name}">
|
||||
<span class="assignee" title={t('task.assignee', { name: assignee.name })}>
|
||||
<span class="face" style="background: {assignee.avatar_color}">
|
||||
{assignee.name[0]?.toUpperCase() ?? '?'}
|
||||
</span>
|
||||
@@ -102,7 +103,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.recurrence_text}
|
||||
<span class="repeat" title="Gentages {task.recurrence_text}">
|
||||
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17 2l4 4-4 4" />
|
||||
<path d="M3 11V9a4 4 0 014-4h14" />
|
||||
@@ -112,6 +113,36 @@
|
||||
{task.recurrence_text}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- What is underneath, said as a fraction rather than a total: "3" tells
|
||||
you there is something there, "1/3" tells you whether there is
|
||||
anything left to do, which is the question the list is for. -->
|
||||
{#if task.subtask_count}
|
||||
<span
|
||||
class="badge"
|
||||
class:all-done={task.subtask_done === task.subtask_count}
|
||||
title={t('task.subtasks', { done: task.subtask_done, total: task.subtask_count })}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 6h11" />
|
||||
<path d="M9 12h11" />
|
||||
<path d="M9 18h11" />
|
||||
<path d="M4 6l1 1 2-2" />
|
||||
<path d="M4 12l1 1 2-2" />
|
||||
<path d="M4 18l1 1 2-2" />
|
||||
</svg>
|
||||
{task.subtask_done}/{task.subtask_count}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.attachment_count}
|
||||
<span class="badge" title={t('task.attachments', { n: task.attachment_count })}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
|
||||
/>
|
||||
</svg>
|
||||
{task.attachment_count}
|
||||
</span>
|
||||
{/if}
|
||||
{#each task.labels ?? [] as label}
|
||||
<span class="label">{label}</span>
|
||||
{/each}
|
||||
@@ -291,6 +322,32 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* A mark, not a chip: it sits in the same row as the date and the labels and
|
||||
must not out-shout them. The task's own text is the thing being read. */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--ink-faint);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.badge svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Everything underneath is closed. Worth saying, because "3/3" and "1/3" are
|
||||
the two answers you actually look for and only one of them is good news. */
|
||||
.badge.all-done {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--ink-muted);
|
||||
background: var(--surface-raised);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* What each recorded activity event is called.
|
||||
*
|
||||
* The keys are the server's and they are stable; this is the only place they
|
||||
* become Danish. It lives in its own module because two surfaces read the same log
|
||||
* now — a project's own history panel, and the administrator's instance-wide audit
|
||||
* page — and two copies of nineteen strings is two places for them to drift apart.
|
||||
*
|
||||
* An unknown key falls through to the key itself rather than being hidden: a build
|
||||
* that records something this table has not learned yet should still show that it
|
||||
* happened.
|
||||
*/
|
||||
export const EVENTS = {
|
||||
'project.created': 'oprettede projektet',
|
||||
'project.updated': 'ændrede projektet',
|
||||
'project.deleted': 'slettede projektet',
|
||||
'project.imported': 'importerede projektet',
|
||||
'section.created': 'oprettede en sektion',
|
||||
'section.updated': 'ændrede en sektion',
|
||||
'section.deleted': 'slettede en sektion',
|
||||
'member.invited': 'inviterede',
|
||||
'member.added': 'tilføjede',
|
||||
'member.removed': 'fjernede',
|
||||
'member.role_changed': 'ændrede rollen for',
|
||||
'task.created': 'oprettede en opgave',
|
||||
'task.updated': 'ændrede en opgave',
|
||||
'task.completed': 'lukkede en opgave',
|
||||
'task.reopened': 'genåbnede en opgave',
|
||||
'task.moved': 'flyttede en opgave',
|
||||
'task.deleted': 'slettede en opgave',
|
||||
'task.split': 'delte en opgave op',
|
||||
'comment.created': 'skrev en kommentar'
|
||||
};
|
||||
|
||||
/** The name of an event, or the raw key when it is one this build does not know. */
|
||||
export function eventName(event) {
|
||||
return EVENTS[event] ?? event;
|
||||
}
|
||||
|
||||
/** The bit of context an entry carries, when it has one worth reading. */
|
||||
export function eventDetail(entry) {
|
||||
const p = entry.payload ?? {};
|
||||
return p.name ?? p.email ?? p.role ?? '';
|
||||
}
|
||||