Code-review fixes and refactors #69

Merged
eliott merged 57 commits from fix/code-review into main 2026-10-15 22:39:35 +02:00
Owner

Code-review pass: fixes, refactors, schema migration and an OpenAPI contract. Release 1.0.0 (breaking for API clients: GET /admin/changelog/:id removed, every DELETE answers 204, migration 0029 adds NOT NULL columns and a 0..10 CHECK on filter exposure compensation).

Includes the unreleased work already on develop (password reset, email change, per-owner names) and the brand update.

Fixes

  • Export builds the archive in a temp file before streaming (slow downloads could exhaust the DB pool), 3/min throttle, one export per user
  • Migration 0028 trims legacy names before the lower(name) unique indexes
  • Old email address is notified on email change; OTP/reset expiries come from shared constants; auth emails sent in the background (no timing enumeration); BETTER_AUTH_URL required in prod; AdminGuard re-reads isAdmin
  • Input gaps that surfaced as 500s (8-digit colors, numeric domain limits, changelog cursor, pagination bounds), import renumbers frames and runs in a worker thread, frame-location upsert/patch semantics
  • What's new shows backdated entries; changelog pages on an index (timestamp(3))

Refactors

  • Catalog controllers from one mixin, shared ownership/shoot-lock helpers, nested selects, single limits source, configureApp() shared with int tests, typed env, DbExceptionFilter, dead code removed, changelog data moved to src/seed/changelog.data.ts
  • Strict TypeScript

Contract

  • POST/PATCH return the full entity (same shape as GET), DELETE 204
  • openapi.json committed, generated by pnpm openapi:generate, CI drift check, contract int test validating every operation

Schema (0029): NOT NULL audit columns, isColor/flash, filter range 0..10, redundant indexes dropped.

CI: nest build, changelog/front version check, gitleaks, docker build, OpenAPI drift.

Tests: 265 unit, 279 integration.

Code-review pass: fixes, refactors, schema migration and an OpenAPI contract. Release **1.0.0** (breaking for API clients: `GET /admin/changelog/:id` removed, every DELETE answers 204, migration 0029 adds NOT NULL columns and a 0..10 CHECK on filter exposure compensation). Includes the unreleased work already on develop (password reset, email change, per-owner names) and the brand update. **Fixes** - Export builds the archive in a temp file before streaming (slow downloads could exhaust the DB pool), 3/min throttle, one export per user - Migration 0028 trims legacy names before the lower(name) unique indexes - Old email address is notified on email change; OTP/reset expiries come from shared constants; auth emails sent in the background (no timing enumeration); BETTER_AUTH_URL required in prod; AdminGuard re-reads isAdmin - Input gaps that surfaced as 500s (8-digit colors, numeric domain limits, changelog cursor, pagination bounds), import renumbers frames and runs in a worker thread, frame-location upsert/patch semantics - What's new shows backdated entries; changelog pages on an index (timestamp(3)) **Refactors** - Catalog controllers from one mixin, shared ownership/shoot-lock helpers, nested selects, single limits source, configureApp() shared with int tests, typed env, DbExceptionFilter, dead code removed, changelog data moved to src/seed/changelog.data.ts - Strict TypeScript **Contract** - POST/PATCH return the full entity (same shape as GET), DELETE 204 - `openapi.json` committed, generated by `pnpm openapi:generate`, CI drift check, contract int test validating every operation **Schema (0029)**: NOT NULL audit columns, isColor/flash, filter range 0..10, redundant indexes dropped. **CI**: nest build, changelog/front version check, gitleaks, docker build, OpenAPI drift. Tests: 265 unit, 279 integration.
User guide fixes:
- password reset and email change flows, per-owner unique names (0026)
- changelog v0.10.0 entry

Audit fixes:
- export: keyset paging in one repeatable-read snapshot, include film ISOs,
  backpressure; import keeps doneAt/isPinned/createdAt, stricter validation
- frames: renumber by current number on delete, lock inside the tx, move
  into done shoot rejected, source shoot recompacted, PATCH returns no body
- explicit null on required PATCH fields is a 400, empty PATCH is a no-op,
  23502 mapped to 400; camera format and film iso ownership validated
- changelog hides future entries, ms cursor precision, capped for new users
- 0027: FK, updated_by, global-row and shoot list indexes; shoot list counts
  frames only for page rows; X-Total-Count on all=true lists
- pg pool limits and statement timeout, async zip inflate, cached crypto keys
- infra: .env excluded from image, multi-stage non-root Dockerfile,
  NODE_ENV=production, graceful shutdown, advisory-locked migrations,
  /health + compose healthcheck, trust proxy, strict auth rate limits,
  compression, log rotation, pinned Node/pnpm/actions, check-only lint
Reviewed-on: #66
Resolve conflicts with the password-reset work and lowercase its new rollnote strings.
The export transaction used to stay open (holding a pooled connection and
a REPEATABLE READ snapshot) for as long as the client took to download,
so a handful of slow downloads could exhaust the pool. The archive is now
written to a private temp file inside the transaction, the transaction
commits, and only then is the file streamed; the file is removed when the
response finishes, fails or is aborted.

Also:
- throttle GET /export to 3/min and allow one concurrent export per user
  (429 with a clear message)
- resolve the referenced settings ids once per export instead of an
  OR + IN (subquery) per page, which EXPLAIN showed scanning every user's
  rows; page frame locations through the user's frames instead of a
  per-page sequential scan of frame_location
- int tests reset the throttler between tests (shared client IP)
Migration 0028 (not deployed yet) now trims every label column with the
same character set as String#trim before relying on lower(name)
uniqueness, so " Portra" and "Portra" can no longer coexist. Rows that
only clash once trimmed are deduped on lower(trim(name)): private rows
clashing with a built-in are renamed, then an owner's later duplicates;
built-in (isGlobal) rows are never renamed.

sameNameAs lowers both sides in Postgres so JS and SQL case folding can't
disagree.

Adds an int test that migrates a scratch database to 0027, seeds legacy
rows and checks the backfill, trim and dedupe paths of 0028.
- once an email change is confirmed, send a notice to the OLD address
  ("your email was changed to x; if this wasn't you, reset your
  password"), from a user.update database hook that only fires for the
  verify-email request that actually switched the address
- OTP_TTL_MINUTES / RESET_PASSWORD_TTL_SECONDS are passed to Better-Auth
  and used in the email copy (the 2FA email said 5 minutes while the
  default period is 3)
- auth emails go out through advanced.backgroundTasks, so responses no
  longer wait on SMTP only for existing accounts (enumeration by timing);
  pending sends are drained on shutdown
- BETTER_AUTH_URL is required in production (email links were otherwise
  built from the Host header)
- AdminGuard re-reads isAdmin from the database instead of trusting the
  5-minute cookie cache
Auth emails no longer block the response, so the sign-up verification
email could land after the mailbox was cleared and skew the counts.
- folder color: strict #rgb / #rrggbb (8-digit alpha colors passed
  @IsHexColor and overflowed varchar(7)); same rule in import validation
- numeric domain limits (ISO, aperture, shutter seconds, exposure
  compensation, latitude/longitude, GPS accuracy/altitude) live in
  common/domain-limits and are enforced by both the DTOs and the import
  validator (an imported aperture of 1e39 was a 500, latitude 500 and
  ISO -5 were stored); legacy shutter labels out of range are dropped
- changelog cursor: strict shape + max length in the query DTO, and
  rolled-over dates are rejected with a 400
- pagination: page capped at 100000, search at 100 characters
An entry is unseen when greatest(published_at, created_at) is after the
user's last visit: release notes are seeded with a publish date before
the deploy that ships them, so comparing published_at alone hid them
from everyone who had opened the modal in between.

Changelog 404s now carry a message.
- better-auth and @better-auth/passkey 1.6.11 -> 1.6.12 (same as rollnote-front)
- eslint and @eslint/js 9 -> 10, globals 16 -> 17 (same versions as rollnote-front)
- run the seed with tsx and drop ts-node (tsx already runs data-encrypt)
- remove the package.json pnpm.onlyBuiltDependencies list: pnpm 10 ignores it,
  pnpm-workspace.yaml allowBuilds is the single build allow-list
- pnpm build (nest build) after the typecheck
- fail when the latest changelog entry in src/seed.ts differs from
  rollnote-front's package.json version (same-named branch, else base branch)
- gitleaks 8.30.1 (pinned, SHA-256 verified) over the full history, with the
  known historical findings baselined in .gitleaksignore
- docker build of the production image (no push) with a pinned, verified
  docker CLI + buildx, image removed afterwards
- timeout 15 -> 20 minutes for the extra steps
- no-new-privileges and a read-only root filesystem on both services
- api: cap_drop ALL, tmpfs /tmp for export spooling
- db: cap_drop ALL + only the caps the entrypoint needs to drop to postgres,
  tmpfs for /run/postgresql and /tmp
- pin postgres to 18.6-trixie in compose.yml and dev.compose.yml
- Node 24+, the .env (app) vs .env.local (dev DB) story, lint is check-only
- schema files are src/db/<domain>-schema.ts, services do join other domains'
  tables, DataWipe lives in src/import/data-wipe.ts
- document deployment hardening and what CI runs
An archive with duplicate frame numbers in a shoot failed with a 409
"please retry" that could never succeed, and gaps broke the gapless
1..N invariant. Imported frames are now renumbered 1..N per shoot,
ordered by (number, createdAt). Two frameLocations for the same frameId
are rejected with a 400 naming both rows.
A re-POST kept the previous fix's altitude/accuracy/capturedAt/label when
the new payload omitted them; every column is now written (null when
omitted). PATCH requires latitude and longitude together, so a lone
latitude can no longer leave a row without a longitude; a coordinate edit
still clears the GPS metadata and re-stamps capturedAt.

POST and PATCH now answer the stored row, shaped like `location` in
GET /frame/:id (PATCH answers 200 instead of 204; an empty PATCH on a
frame without a location answers an empty body).
One write contract for every resource:
- POST answers the created entity and PATCH the updated one, shaped
  exactly like GET reads it back: the list item for the catalog entities
  (camera with its filmFormat object, film with iso {id, value}) and
  folder, GET /shoot/:id for shoot (also PATCH /pin and /done), and
  GET /frame/:id for frame (also POST /:id/duplicate). Shoot and frame
  POST used to answer only {id}; the new body is a superset. An empty
  PATCH answers the current entity.
- DELETE answers 204 with no body everywhere, DELETE /shoot/:id/frames
  included.
- GET /frame/:id selects explicit weather/lens/filter columns, so it no
  longer exposes who created or edited those rows.

response-shapes.int-spec checks POST/PATCH bodies against GET for each
entity.
The global filter logged only the message and request id of an exception that ends as a 500, leaving no way to find the failing code path. The Error's stack now rides along as the logger's stack argument.
Both specs only checked typeof === 'function'. They now read the decorators back from Nest's route-args metadata: CurrentUserId's factory returns the user id and throws 401 without one, IdParam reads the right param name and its pipe accepts a v7 UUID and rejects other strings with 400.
seed.int-spec runs the real seed() twice and expects the second run to insert nothing (then removes what it added so other specs keep their built-in counts). Film format gets the built-in clash, rename and 23505 race tests film already had.
An import ran up to a 64 MiB JSON.parse, the structural validation and the
row plan synchronously on the main thread, stalling every other request.
That work now runs in a one-off node:worker_threads worker
(import.worker.ts, started by runImportInWorker); the main thread only
loads the matchable rows before and inserts the planned rows after. A
preview asks for counts only, so the rows are not copied back.

Limits are unchanged. An HttpException thrown in the worker is rethrown
with the same status and body (400 for a bad archive, 422 for a film
without an ISO); anything else is a 500 carrying the worker's stack.
Under jest the .ts entry point is loaded through tsx (a dev dependency),
so the tests exercise the real worker; `node dist` and `nest start` run
the compiled import.worker.js.

The matchable rows are now read before the commit transaction rather
than inside it, so the transaction is not held open while the worker
parses; they never depended on the wipe.
env.schema.ts now exports `env`, the validated environment parsed once on
first import (loading .env first). main.ts, db.ts, auth.ts, email.ts and
the encryption key read it instead of `process.env.X!`, so the validated
result is no longer thrown away. SMTP_PORT and PORT are coerced to integer
ports (a blank value still counts as unset). Better-Auth gets its secret
and base URL from the same object.

Unit tests load a shared test env (test/unit-setup-env.ts) so modules that
import env can be loaded; the key-rotation test now loads a second copy of
the encryption module instead of mutating the env at runtime.
Trust proxy, CORS, helmet, compression and the global ValidationPipe move
from main.ts into configureApp(), which the integration-test app now calls
too: the tests used to run with plain helmet() and none of the rest. The
ValidationPipe options live in createValidationPipe() so a handler with an
erased parameter type can reuse them. New app-setup int tests pin the CORS
preflight, exposed headers, HSTS/CORP, gzip, the trusted proxy hop and
whitelist rejection.
The filter handles every exception, not only FK violations, so it is now
DbExceptionFilter (file and class). Its table labels are keyed by
getTableName() of the schema tables instead of hand-typed table names, and
the duplicate-name 409 texts come from common/duplicate-messages.ts, shared
with the services' uniquenessClash checks (same wording as before).

Unhandled errors were logged twice: our line with the request id and stack,
then Nest's ExceptionsHandler line. handleUnknownError now replies exactly
like Nest (500, or the http-errors status) and logs only our line.
common/domain-limits.ts becomes db/limits.ts and also holds MAX_LENGTH, the
varchar lengths the schema files, the request DTOs and the import validator
used to spell out separately (no schema change: drizzle-kit generates
nothing). RequiredLabel(max) replaces the TrimString/IsNotEmpty/IsString/
Length stack on the names, and OptionalRef() the IsOptional/IsString/IsUUID
stack on the optional references; both carry swagger metadata so the
limits stay visible once the swagger CLI plugin lands.

Every body id is now checked as a v7 UUID (ID_UUID_VERSION), like IdParam
already does for paths; every id the API mints, seeded rows included, is a
v7. The redundant @IsString next to @IsUUID is gone, so a non-string id
gets one "must be a UUID" message instead of two.
ownedRow() (id + createdBy match), assertOwnedRow() (404 "<entity> not
found") and lockShootForWrite() / lockShoots() / assertShootWritable()
(FOR UPDATE in id order, 404 for a foreign shoot, 400 with the caller's
message for a completed one) live in common/db-helpers.ts. They replace
the hand-written owner checks in the CRUD base, folder, frame,
frame-location and shoot services, and the three copies of the shoot lock
block (frame create, frame move/duplicate, clear frames). Same queries,
statuses and messages.
ShootService builds its list and detail items from one shootColumns(src)
builder (read off `shoot` or the paging CTE) with Drizzle nested selects,
instead of two flat column maps, the ShootBaseRow type and mapShootBase()
with its non-null assertions; Drizzle already maps an all-null LEFT JOIN
to null. mapPage() lost its last caller and goes.

OwnedCrudConfig gains extraColumns/joins: findAll and the POST/PATCH
re-select (toItem) both build the item from them, so camera and film
declare their nested film format / ISO once and no longer override
findAll or toItem. Responses are unchanged, key order included.
camera, lens, filter, iso, weather, film and film-format had near-identical
controllers. createOwnedCrudController(Service, CreateDto, UpdateDto)
builds the list/create/update/delete routes once; each controller now
just extends it under its @Controller path, and ownedCrudModule() builds
the module. Routes, status codes, interceptors and the swagger document
(operation ids, request bodies) are unchanged.

The body parameters are generic, so their DTO class is not emitted in
design:paramtypes and the global ValidationPipe would skip them: each
@Body gets its own pipe with the DTO class (expectedType). The new
owned-crud int spec proves, for all seven resources, that unknown
properties, invalid create bodies and invalid PATCH bodies still answer
400 and that bodies are still transformed (trimmed names).
- OwnedCrudConfig.hasUpdatedBy: every CRUD table has updatedBy.
- setIsGlobalOnCreate: isGlobal defaults to false and the DTO whitelist
  already rejects a client-sent isGlobal (iso never had it).
- The changelog relations(): nothing uses the relational query API.
- GET /admin/changelog/:id and ChangelogService.findOne: neither the
  front nor the app calls it (the admin page edits from the list).
- Idempotency-Key in the CORS allow-list: no front or app build still
  sends it; the preflight now allows Content-Type and Authorization only.
- Orphan or stale comments in data-wipe.ts, frame-location.service.ts
  (no second query, no 403) and import.constants.ts ("tunable via env").
The ~300 lines of changelog releases move to src/seed/changelog.data.ts
next to the other seed data; seed() inserts them exactly as before. The
CI check that the latest changelog version equals rollnote-front's
package.json now greps that file (same result locally: v0.10.0), and
CLAUDE.md points there.

SYSTEM_USER_ID is declared once (src/seed/system-user.ts) and shared by
the seed and the int-test helpers, and every id is minted through newId
(db/id-column.ts) instead of importing uuid's v7 directly.
Each email is now one EmailLayout (subject, preheader, heading, intro, a
link or code action, footer note) that send() renders twice: the branded
HTML and the plain-text part. The hand-written text copies, which had
drifted from the HTML wording, are gone; the text now carries the same
heading, paragraphs, link or code and footer as the HTML.

New snapshot tests pin the rendered output of all five emails. The HTML
snapshots were recorded before this change and still match byte for
byte; only the text snapshots changed.
owned-crud.int-spec.ts runs the scenarios the seven catalog specs each
repeated (401, owner isolation and built-ins visible to all, owner-only
PATCH/DELETE with 404 for others, read-only built-ins, pagination and
search, pagination query 400s, body validation) once per resource. The
camera, film, filter and iso specs keep only their entity-specific cases;
lens, weather and film-format had none left and are removed.

audit-fixes.int-spec.ts is split into the domain specs (export, import,
frame, shoot, folder, frame-location, reference-validator, and the
global-aware search into owned-crud). Export/import tests share
helpers/archive.ts (download, read, edit and post an archive) and the
shoot/frame tests createShoot/createFrame fixtures.
createdAt/updatedAt/createdBy/updatedBy were spelled out in eleven schema files. They now come from db/audit-columns.ts; the generated schema is unchanged (drizzle-kit generate finds nothing to migrate).
The migrate-to-before/seed/migrate-to-latest steps of the 0028 test move to helpers/scratch-db.ts so the 0029 test can reuse them; the legacy rows are inserted through insertEach instead of one loop per table.
- createdAt/updatedAt on every domain table are backfilled (from the
  other timestamp of the row, a location's capturedAt, else now()) and
  become NOT NULL.
- film.isColor: NULL becomes true (the front's default), NOT NULL
  default true. frame.flash: NULL becomes false, NOT NULL; the frame DTO
  now rejects an explicit null flash like bulb.
- filter.exposureCompensation is the stops of light a filter takes away:
  negatives become their absolute value, everything is capped at 10, and
  a CHECK constraint keeps it in 0..10. The DTO takes 0..10; imports map
  older values the same way instead of rejecting them, and default the
  NULL isColor/flash of old archives.
- Drop film/iso/weather_created_by_idx: the per-owner unique indexes
  lead with created_by.
- shoot_list_idx becomes a plain ascending index, read backwards for the
  list ORDER BY.
- changelog_entry.published_at becomes timestamp(3), truncated to the
  millisecond.
published_at is timestamp(3) since 0029, so the list orders and compares cursors on the bare column instead of date_trunc(...) and changelog_entry_published_idx serves it (Index Scan Backward instead of Seq Scan + Sort). /changelog/since returns at most the 50 newest unseen entries.
The text part rendered the same intro as the HTML, so it said "Tap the button below" with no button in sight. Intros that point at the action now take the wording from the renderer: "Tap the button below" in the HTML (unchanged, snapshots identical), "Open the link below" in the text part.
jest.unit.config.cjs already loads reflect-metadata in setupFiles (and @nestjs/common requires it). The explicit imports made fallow report the runtime dependency as test-only.
import-plan.ts only needs the tables' insert types, so it imports the schema namespace as types. ImportService takes its tables from the db/schema barrel, and loadMatchableRows builds the built-ins-or-own filter once instead of four inline ternaries.
getMailer() only caches; createMailer() builds the transport and requireSmtpVars() names every unset var in one pass. Same error text as before, now covered by a unit test.
create() spreads the provided fields over NO_LOCATION instead of seven '?? null' lines; omitted fields are still stored as NULL.
afterCursor() builds the keyset condition, withItems() attaches the items for both the paged list and /since, and the next cursor is read off the last row of the page.
createdAtColumn/updatedAtColumn take the column name, so changelog_entry's created_at/updated_at come from them too. drizzle-kit generate finds nothing to migrate.
listSince() reads as: when did the user last look, which entries are newer, newest first, capped.
useAliceAndBob() replaces the server/alice/bob beforeEach copied into the two-user specs, setShootFlag() the pin/done PATCHes, and each spec names its repeated requests (postLocation, framesOf, sinceTitles, expectDeleted, ...). Assertions are unchanged.
Turn on "strict" (and noFallthroughCasesInSwitch) in tsconfig.json, which
also covers the tests (CI typechecks with tsc -p tsconfig.json), and drop
the no-explicit-any override from the ESLint config.

The only fallout was strictPropertyInitialization on the request body DTOs:
their required fields are assigned by ValidationPipe from the validated
body, never by a constructor, so they carry a commented `!`.
Enable the @nestjs/swagger CLI plugin (nest build only) so request and
response classes carry their schemas, and give every handler its success
status and response type:

- one response DTO per shape, next to the module's request DTOs: catalog
  items (camera with filmFormat, film with iso {id, value}), folder, shoot
  list item vs detail, frame row vs detail with its joins, frame location,
  changelog page/since/entry, import preview/result, health (and its 503);
- list endpoints document both answers (ApiListResponse): the page
  envelope, or with all=true a bare array plus X-Total-Count;
- 201 for POSTs, 204 for DELETEs and changelog/seen, the zip of GET /export
  and the multipart body of the import routes.

Nullable columns and unset references are marked nullable. Update DTOs use
@nestjs/swagger's PartialType (same validation, but it carries the schema),
so @nestjs/mapped-types is no longer a direct dependency. The pagination
query marks its defaulted fields optional, and controllers declare their
return types so a service drifting from its DTO fails to compile. The
document is titled "RollNote API" and versioned from package.json; Swagger
stays off in production.
`pnpm openapi:generate` runs nest build (the swagger plugin only runs
there) and then dist/src/scripts/generate-openapi.js, which instantiates
the app module without initializing it and writes openapi.json at the repo
root with sorted keys. It needs no database or mail relay: the env is
forced to placeholders and the pg pool never connects, so the output does
not depend on the caller's .env either.

CI regenerates the file after the build and fails when the committed one
differs. CLAUDE.md and the README describe the script and the check.
The document must list exactly the routes the controllers serve, and every
documented operation is called for real: each answer has to be a
documented status, a body-less one for 204, and a JSON body matching its
schema. A small validator walks the schemas the document uses ($ref,
allOf, oneOf, nullable, enum, date-time) and treats objects as closed, so
a field a response sends but the document misses fails as well.
Optional request fields the API takes null for (to clear a nullable column)
were not documented as nullable, so typed clients could not send that null.
They are now typed `T | null`, which the swagger plugin marks nullable:
filter exposureCompensation, frame notes/aperture (and the weather/lens/filter
refs, typing only), shoot comment, weather description, frame location fields,
and the changelog version.

The changelog publishedAt and item position (NOT NULL columns) now reject an
explicit null with a 400 instead of silently treating it as omitted.

The contract int test checks every JSON request body it sends against the
document, and that each request field is nullable exactly where validation
accepts null.
Follow the brand convention from origin/develop: the OpenAPI document
title and description say "rollnote", and the email HTML snapshots pick
up the lowercase wordmark and copy.
chore(release): 1.0.0 and the v0.11.0 front changelog entry
Some checks failed
tests / test (pull_request) Failing after 1m24s
089b69efd0
Breaking for API clients (hence 1.0.0): GET /admin/changelog/:id is
removed, every DELETE now answers 204, and migration 0029 adds NOT NULL
constraints and a 0..10 CHECK on filter exposure compensation.
assertNoClash and listWhere share a visibleTo() scope helper and the
excluded-id condition moves to otherThan(); classify delegates the
throwing decrypt to tryDecrypt() and the optional round-trip check to
roundTripsIfChecked(). Brings both under fallow's CRAP threshold with
no behaviour change.
test: share setup helpers across duplicated spec blocks
All checks were successful
tests / test (pull_request) Successful in 4m28s
c9af74d937
email.spec gets expectSentMail(), zip-parser.spec makeStoredDataZip(),
frame.int-spec patchThenGet() (and reuses createFrameFor), and
import.int-spec createAsAlice(), removing the clone groups fallow
flags.
eliott merged commit 404777a270 into main 2026-10-15 22:39:35 +02:00
eliott deleted branch fix/code-review 2026-10-15 22:39:36 +02:00
Sign in to join this conversation.
No reviewers
No labels
run-ci
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
RollNote/rollnote-api!69
No description provided.