This file serves the developer(s) and coding AI. It's goals are:
- ensure consistent coding
- facilitate introducing new devs / AI's
Primarily css modules are used. Dynamic styling is usually implemented via inline styles.
For some general styles a global .css file is used.
Use css directly, without modules, as mentioned here: https://medium.com/@rapPayne/stop-writing-react-local-styles-use-the-component-classname-pattern-9a820c6447de
- create a .css file with the same name as the (single) component file
- give the base class the same name as the component (important: prevent name conflicts?)
- give the base div of the component the base class
- use css nesting to style all the component's elements in the component's .css file
- add dynamic styling by dynamically changing classes (there may be cases where inline does not work?)
Is this better than using css modules? Migrating would be a chore, so let's not do it now.
One upside is: inside css modules nesting is not possible for classes.
This project uses SQL files in multiple locations:
backend/db/init/backend-dev/db/init/src/sql/backend/db/andbackend-dev/db/for shared generator/test SQL assets
To avoid manual drift, backend/db/init/ is the single source of truth.
Edit SQL files only in:
backend/db/init/backend/db/for these shared files:generate_apflora_seed_sql.mjsgenerate_qcs_sql.mjstest_history_tables.sqltest_history_tables_smoke.sqltest_history_tables_full_coverage.sql
Do not manually edit mirrored copies in backend-dev/db/init/ or src/sql/.
Do not manually edit mirrored copies in backend-dev/db/ for the shared files listed above.
npm run sync-sqlThis does:
- Mirrors all files from
backend/db/init/tobackend-dev/db/init/. - Mirrors selected shared files from
backend/db/tobackend-dev/db/:generate_apflora_seed_sql.mjsgenerate_qcs_sql.mjstest_history_tables.sqltest_history_tables_smoke.sqltest_history_tables_full_coverage.sql
- Copies selected files into
src/sql/:01_immutableDate.sql->immutableDate.sql04_createTables.sql->createTables.sql07_triggers.sql->triggers.sql08_syncIgnoreDuplicateInsertTriggers.sql->syncIgnoreDuplicateInsertTriggers.sql
npm run sync-sql:check- Exit code
0: everything is in sync. - Exit code
1: one or more mirrored files are out of sync.
A git pre-commit hook is configured to run:
npm run sync-sql:checkIf files are out of sync, the commit is blocked.
npm install
npm run hooks:installhooks:install sets:
git config core.hooksPath .githooks
- Edit SQL in
backend/db/init/. - If editing Apflora taxonomy CSVs in
seed-data/apflora/, regenerate the seed file:
node backend/db/generate_apflora_seed_sql.mjsThis rewrites:
backend/db/init/11a_seedApfloraTaxonomies.sql
- Run
npm run sync-sql. - Commit changes.
If commit fails on SQL sync check:
- Run
npm run sync-sql. - Re-stage updated files.
- Commit again.
backend/db/init/09_seedQcs.sqlRegenerate with:node backend/db/generate_qcs_sql.mjsbackend/db/init/11a_seedApfloraTaxonomies.sqlRegenerate with:node backend/db/generate_apflora_seed_sql.mjsbackend/db/init/11b_seedApfloraExampleData.sqlRegenerate with:npm run apflora:generate
Goal: real apflora.ch example data in the dev backend, to explore editing, presentation and analysis and to find missing features compared to apf2.
Imported per repeatable command (source: local pg_dump of the apf2 backend-dev
database, default ../apf2/backend-dev/db/apflora.backup, override with
APF2_DUMP):
- project
apflora, owned by the demo account (as with the demo project) - subprojects: Abies alba, Aldrovanda vesiculosa, Pulsatilla vulgaris
- place levels: 1 = Populationen (apf2:
pop), 2 = Teil-Populationen (apf2:tpop) - checks on level 2 (apf2:
tpopkontr, without Freiwilligen-Kontrollen; Ausgangszustand included), with counts ascheck_taxa(apf2:tpopkontrzaehl) - actions on level 2 (apf2:
tpopmassn; apf2's inline count columns likeanz_pflanzenare kept as plain fields, not mapped toaction_taxa) - historizations (apf2:
ap_history,pop_history,tpop_history— one snapshot per(year, row), usually for the years in which a report was written) assubprojects_history/places_historyversions (see below) - yearly place reports (apf2:
popber/tpopber→check_reports,tpopmassnber/popmassnber→action_reports) — the source of the report's B and C tables - not imported: Beobachtungen, Berichte (ap-level), Freiwilligen-Kontrollen
Pipeline (run from the repo root):
npm run apflora:extract # apf2 dump -> seed-data/apflora/apf2-example.json
npm run apflora:generate # JSON -> backend/db/init/11b_seedApfloraExampleData.sql
npm run sync-sql
cd backend-dev && docker compose down -v && docker compose up -d --buildThe extracted JSON and the generated SQL file are local-only (gitignored):
they contain unpublished apf2 data, and the apf2 repo/dump is expected to exist
whenever the import is (re)run. All generated ids are deterministic
(md5-derived, uuidv7-shaped), making the seed idempotent (ON CONFLICT DO NOTHING); row-count asserts at the end of the file fail init loudly if
anything is missing. Clones without these files simply seed without the
apflora example project.
The file must run after 11a_seedApfloraTaxonomies.sql (it links into the
seeded DB-TAXREF (2017) taxa) and before 12_writePermissionTriggers.sql
(which rejects non-JWT writes). Note: the docker entrypoint sorts init files
with en_US.utf8 collation, where underscores are ignored in comparisons —
that is why the files are numbered 11a/11b rather than sharing an 11_
prefix.
Potential missing features, surfaced by mapping apf2 data into ps:
- Counting method lost: apf2
tpopkontrzaehl.methode(geschätzt/gezählt) has no counterpart oncheck_taxa/check_quantities. - Subset counts double-count: apf2 einheiten like "davon blühende
Pflanzen" are subsets of "Pflanzen total". ps units are flat — summing
check_taxaper check mixes totals with subsets. - No person directory: apf2 links
adresserecords (Bearbeiter, EK-Kontrolleur). ps fields are free text — no consistency or autocomplete. - No control planning: apf2
ekfrequenz(interval in years + start year + abweichend) drives "fällige Kontrollen". ps has no equivalent; only the start year/flag were imported as fields. field_sortshas no level column, butfieldsforplacesexist per level — one sort order per table is shared across levels.- apf2 keeps
jahranddatumseparately; ps checks/actions only havedate(rows with only a year were seeded with January 1st of that year). - Massnahmen counts are inline in apf2 (
anz_pflanzen,anz_triebe,zieleinheit_*as columns) while ps offersaction_quantities/action_taxa— the import keeps them as plain fields because the unit semantics ("Pflanzen total" vs. planted plants) don't map 1:1.
Reports are per year (subproject_reports.year). Rows that carry their own
date — checks, actions, goals — are simply filtered by the report year. Rows
without a date — subprojects and places (status, apber_relevant, start_year)
— may have changed since, so their state for a past report year is read from
historization.
- Historizations are dated Dec 31, 12:00 UTC of the year they describe
(
sys_periodlower bound). Each version runs until the next historization of the same row; the last one ends at Jan 1 of the seed's generating year, where the live rows' trigger-maintainedsys_periodtakes over. - A report for year Y reads, per row, the last version whose
sys_periodcontains the end of Y (Dec 31 12:00 UTC) — i.e. the last historization before the end of the report year. Rows without a valid version (not yet existing, or deleted back then) drop out of that year. - Consequently only years with historizations exist as report years — plus the current one, which the live rows cover.
- Nothing is synthesized: the import only maps the historizations apf2 actually contains.
-
Import:
extract_apflora_example.mjspullsap/pop/tpop_historyintoapf2-example.json(keyhistoriesper row);generate_apflora_example_sql.mjsmaps them toplaces_history/subprojects_historyrows, creating the yearly partitions it needs (the history tables are partitioned byupdated_at) and settingREPLICA IDENTITY FULLon all partitions — the seed's idempotentDELETEs require it under the Electric publication, because history tables have no primary key. -
Runtime: history tables are server-only (not synced to clients). Reports query them directly via PostgREST —
src/components/shared/reportVersions.tsfetches live + history versions of the art's subprojects/places (online only) and caches them with react-query (staleTime: Infinity— history is immutable, so repeatedly opened reports are served from the cache).asOfYear()picks the valid version per row. -
Consumers: the data-driven report tables (
reportComponents.tsx) are 1:1 ports of apf2'sjber_abcSQL (apf2/sql/apflora/functions/jber_abc.sql), so the printed numbers equal apf2's AP-Bericht: A (Grundmengen) counts pop/tpop statuses from the as-of rows (tpops under non-potential pops,bekannt_seitvs the AP start year decides vor/nach AP, places withoutbekannt_seitnever count); B (Bestandesentwicklung) counts the yearlycheck_reports; C (Zwischenbilanz) counts actions plus the latestaction_reportsbeurteilung per place. The place-based chart series (buildData) compute from the as-of rows; dated series (checks/actions) stay on the local, year-filtered data. Like apf2's chart functions (tpop_kontrolliert_for_jber,pop_nach_status_for_jber,ap_ausw_pop_menge), report charts only show the historized years up to the report year — plus the current year when it is the report year. The place series followtpop_kontrolliert_for_jberexactly: per year the historization snapshot without potential places (status 300) and withoutbekannt_seit, tpops only under a qualifying pop and if report-relevant, and erloschen tpops (status 101/202) only in the first year of that status. The kontrolliert series countscheck_reports(apf2:tpopber),The Populationen nach Status chart builds apf2's six A-table series (ursprünglich / angesiedelt vor/nach Beginn AP / erloschen vor/nach / Ansaatversuch, with apf2's colors) per historization year, using the start year of the subproject's version of that year —
pop_nach_status_for_jberported 1:1. AProgrammInfobuilding block shows Start Programm (the subproject's start year), Erste Massnahme and Erste Kontrolle (the earliest action and check years), like apf2's report header. The Triebe total chart portsap_ausw_pop_menge: per historization year, every qualifying tpop carries its latest zaehlung of the zielrelevant unit up to that year (lookback), plus the year's anpflanzung planting when it had no zaehlung; series per population, ursprünglich ones translucent green, angesiedelt orange. not checks. While offline or before the versions arrive, the current local state is used as fallback. -
Demo: the 2020 Aldrovanda report (seeded in
11d_seedApfloraReport.sql) is calculated from the 2020 historizations; compare with the 2025 report to see the difference.
- Sync script:
scripts/sync-sql.mjs - Hook installer:
scripts/install-git-hooks.mjs - Hook file:
.githooks/pre-commit - NPM scripts:
package.json
App-admin access is configured via frontend env variable:
VITE_APP_ADMIN_EMAILS
Format:
- Comma-separated email list
- Example:
VITE_APP_ADMIN_EMAILS=alex@gabriel-software.ch,alex.barbalex@gmail.com
Behavior:
- Emails are trimmed and compared case-insensitively.
- If the variable is empty or missing, nobody is treated as app admin.
- Do not hardcode admin emails in routes/components. Use
src/modules/appAdmins.ts.
Users register with email, password, and an auto-derived display name (the local part of the email address). The name field is required by the backend and is set automatically — the frontend derives it from the email before calling signUp.email().
After successful registration the user is taken to the sign-in form and shown a success message advising them to check their email.
New users can log in immediately after registration without verifying their email. They have 1 hour from account creation to complete verification before they are automatically signed out.
-
Grace window — defined in
src/modules/emailVerificationGrace.ts:EMAIL_VERIFICATION_GRACE_MS = 60 * 60 * 1000(1 hour)getVerificationDeadlineMs(user)— returnsuser.createdAt + 1 hrornullif already verified or missingisVerificationGraceExpired(user, nowMs?)— returnstruewhen deadline has passed
-
Route guard —
src/routes/data/route.tsxbeforeLoadchecksisVerificationGraceExpired. If expired, it redirects to/auth?verificationExpired=true, which shows an error message on the auth page. -
In-app banner —
src/components/EmailVerificationBanner.tsx, mounted insrc/components/LayoutProtected/index.tsx:- Visible whenever the session user has
emailVerified: falseand is still within the grace window. - Shows a live countdown (
HH:MM:SS) to forced logout. - Resend button — POSTs
{email, type: 'email-verification'}to/auth/email-otp/send-verification-otp. - OTP input + Verify button — POSTs
{email, otp}to/auth/email-otp/verify-email; reloads the page on success. - When the countdown reaches zero,
signOut()is called automatically and the user is redirected to/auth?verificationExpired=true.
- Visible whenever the session user has
| File | Purpose |
|---|---|
src/modules/emailVerificationGrace.ts |
Grace window helpers & expiry check |
src/components/EmailVerificationBanner.tsx |
Countdown banner with resend + OTP verify |
src/components/EmailVerificationBanner.module.css |
Banner styles |
src/routes/data/route.tsx |
Route guard that enforces expiry |
src/routes/_layout.auth.tsx |
Adds verificationExpired search param to auth route |
src/components/Auth.tsx |
Shows grace-expired error and post-signup success message |
backend-dev/auth/auth.mjs |
requireEmailVerification controlled by REQUIRE_EMAIL_VERIFICATION env var |
In backend-dev, email verification is disabled by default (REQUIRE_EMAIL_VERIFICATION env var defaults to false). This means new users can log in without any OTP step.
To test the full OTP flow locally when Mailgun is not configured, check the auth container logs — the OTP is printed there:
docker compose logs auth --tail 50To enable verification in dev, set REQUIRE_EMAIL_VERIFICATION=true in the dev environment and restart the auth container.
A user can delete their own account from the user form (src/formsAndLists/user/index.tsx) in the Data section.
- A confirmation dialog (
src/formsAndLists/user/DeleteAccountDialog.tsx) warns the user that deletion is permanent and irreversible, and hints that they can export their data first. - On confirmation, a
DELETE FROM users WHERE user_id = $1is sent directly to the PostgREST API (constants.getPostgrestUri()). Referential integrity (cascade deletes) removes all related data server-side. - After a successful delete,
clearLocalSyncedData()is called to wipe local PGlite state, IndexedDB databases, browser caches and persisted localStorage. - The browser is redirected to
/.
| File | Purpose |
|---|---|
src/formsAndLists/user/index.tsx |
"Delete account" button in the Data section |
src/formsAndLists/user/DeleteAccountDialog.tsx |
Confirmation dialog; executes the delete + wipe |
src/modules/clearLocalSyncedData.ts |
Clears all local sync state and caches |
The user-facing documentation of this model lives in docs/docsMd/user-roles_{de,en,fr,it}.md — it is the source of truth for the roles model. Keep it correct and translated in all four languages.
- User owns own user row, related accounts, projects and other data
- Roles are (from high to low): owner, designer, writer, reader
- Projects, subprojects and places have
..._userstables to set a user's role - A users role always includes all the lower roles. They are not separately set, only a single role is set
- (Only) Owners can set designer roles
- (Only) Owners and designers can set writer and reader roles
- Only triggers set owner roles, users can't
- When a role is set, it's effect extends down all relations (n-sides) - even if (which should not happen) it has not been set in a
..._userstable in between. - Setting lower rights at a lower level is not expected. Example: When a user has reader role on project, all its data can be synced without checking lower levels
- Higher rights can be given at lower levels, their effect extending down as well. Example: A reader who shall be writer on a subproject needs the reader role on its project to sync in parent data
- Setting lower roles at higher levels after having set higher ones lower down will nuke higher roles at lower levels. That's a problem we will have to live with? Will have to inform users if this happens in projects/subprojects
- Owners are recognized by the 'owner' role given (the trigger that sets the owner roles uses above definition of what a user owns)
- Only the owner of an account (
accounts.user_id) can create projects in that account. Enforced inenforce_projects_write(backend) andcheckWritePermission(client)
-
Read above rules to understand why and how to implement
-
We do not need sql to migrate existing implementations. After this rebuild we will rebuild the (experimental) production server from scratch
-
account_id is currently part of many tables. In most cases it is redundant and should be removed. Keep it in: accounts and projects. Ensure there is no code left referencing it
-
Roles: Change
user_roles_enumto be('reader', 'writer', 'designer', 'owner'). Add them in this order to make this the official, sortable and comparable order (https://www.postgresql.org/docs/current/datatype-enum.html#DATATYPE-ENUM-ORDERING) -
Ensure code using user_roles_enum is updated, i.e. /home/alex/Documents/GitHub/ps/src/modules/constants.ts.userRoleOptions, /home/alex/Documents/GitHub/ps/backend/db/init/10_seedGeneralTestData.sql (manager role no more needed as trigger will set owner), comments in createTables sql files, /home/alex/Documents/GitHub/ps/src/components/Tree/Project/Editing.tsx.userMayDesign, /home/alex/Documents/GitHub/ps/src/formsAndLists/project/DesigningButton.tsx.userMayDesign. Ensure the previous roles are no more used anywhere and replaced in a meaningful way
-
Ensure a user can have only a single role in a
..._userstable (combination of parent table id and user_id must be unique) -
Build on update, on insert and on delete of
..._userstables triggers to upsert or remove roles in lower (n-side)..._userstables -
On insert triggers in projects set this users role to 'owner'
-
On insert triggers in subprojects and places tables fetch and set this users roles from the parent (next up in the hierarchy)
..._userstable -
Ensure triggers dont cascade recursively: use
pg_trigger_depth()(https://www.postgresql.org/docs/9.2/functions-info.html) to only run onWHEN (pg_trigger_depth() < 1). See: https://stackoverflow.com/a/14262289/712005 and https://dba.stackexchange.com/a/163152/51861. Beware: this will not work in casee where the spreading trigger should react to a different trigger. Which is what we want: only run when a user (with the needed rights) changes rights. The trigger thus has to update ALL lower level..._userstables -
Ensure these triggers do not run on sync (using
current_setting('electric.syncing', true)as for instance in observation_imports_label_creation_trigger) -
Add subqueries (https://electric-sql.com/docs/guides/shapes#subqueries-experimental) to shape params in /home/alex/Documents/GitHub/ps/src/modules/startSyncing.ts to ensure only allowed rows are synced in (user has reader or higher role in the relevant parent table which is projects, subprojects or places set in the respective xxx_users table). Keep an eye on whether these subqueries are reasonable or if we need to create user-hidden xxx_users tables fed by triggers
-
Alter app side write operations to respect roles and surface when writer or higher role is missing
-
Alter postgrest API requests to send an authorization header that is checked on the server. Return meaningful messages if authorization fails. App-side roll back operation. Done: JWT Bearer token sent on all PostgREST writes; JWT errors invalidate the token cache and notify the user; permission-denied (42501) errors revert the optimistic change in PGlite, remove the queued operation, and show a notification. See
src/modules/fetchPostgrestToken.ts,executeOperation.ts,observeOperations.ts. -
Alter postgrest API to ensure user may run this write operation according to the rules above. If not return a meaningful message which is surfaced in the ui and rolls back the operation that caused it Done:
backend/db/init/12_writePermissionTriggers.sqladds BEFORE triggers (WHEN pg_trigger_depth() < 1) on all project/subproject/place-scoped tables. Each trigger reads the JWTuser_idviaget_jwt_user_id(), checks the role hierarchy viauser_can_write_project/user_can_write_subproject/user_can_write_place, and raises a42501exception with a descriptive message + hint if access is denied.*_userstables require designer+ viauser_can_manage_*_roleshelpers. ElectricSQL sync is skipped viais_electric_sync(). The app-side42501handler inobserveOperations.tssurfaces the hint text and reverts + removes the operation. -
Alter electric-sql endpoint to accept only authorized requests: https://electric-sql.com/docs/guides/auth#proxy-auth
- Added
GET /auth/electric/checkto both auth servers: stateless HS256 JWT verify usingPGRST_JWT_SECRET, no DB lookup - Dev
Caddyfile:forward_auth @notOptions localhost:3003 { uri /auth/electric/check }on thelocalhost:3001Electric proxy - Prod
backend/caddy/Caddyfile: sameforward_authon bothsync.xn--arten-frdern-bjb.appandsync.promote-species.app startSyncing.ts: fetches the PostgREST JWT viafetchPostgrestToken()at startup and passes it asAuthorization: Bearerheader to every shape subscription via theObject.fromEntriesshape map
- Added
-
Critical for speed: Updates on role changes high up in the hierarchy: should happen batched
-
Critical for speed: Sync subqueries
-
Critical for speed: Write checks, especially when data is imported. Batch imports!
-
Most critical for speed: Ensure that changing a role does not lead to re-syncing already synced rows other than
..._usersor for users not involved. This rules out the array-column per role approach!
You can now log in at the dev backend as test@test.ch / test-test and see all the seeded data (email verification is disabled in dev; accounts older than the 1-hour grace period need email_verified = true in the db)
The current model conflates two concepts in the global users table: login identity and project collaborators. The rebuild separates them. See also docs/docsMd/user-roles_{de,en,fr,it}.md — the user-facing doc must be rewritten after the rebuild.
- Login identity is global. A person logs in once; login emails are globally unique. The
userstable serves auth only (email, email_verified, name) and is never exposed in project UIs or synced to other users' devices beyond their own row. - Collaborator identity is project-scoped. Each project keeps its own directory of people (
project_users), keyed by email. Emails may overlap across projects; within a project an email identifies exactly one collaborator. - People and roles are separate.
project_userslists people;*_rolestables assign roles at three symmetric scopes (project, subproject, place level 1/2). - Accounts own projects and finance the app. Only the account owner may create projects in it (rules 13, enforced in
enforce_projects_writeandcheckWritePermission). - Owner roles are set only by triggers (rule 7).
- The role enum stays:
('read-specific', 'read-all', 'write-specific', 'write-all', 'design', 'own'). One role per user per scope, explicit via UNIQUE constraint.
-- auth only (better-auth user table); NOT referenced by project data anymore
users (user_id, email UNIQUE, email_verified, name, ...)
-- billing unit; owns projects. email = billing contact
-- (may default to the owner's auth email)
accounts (account_id, user_id -> users, email, type, ...)
-- per-project directory of collaborators
project_users (
project_user_id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES projects,
email text NOT NULL, -- trimmed, lowercased on write
auth_user_id uuid DEFAULT NULL REFERENCES users, -- stamped on claim (see below)
UNIQUE (project_id, email)
)
-- role assignments: same shape at every scope
project_roles (project_role_id PRIMARY KEY, project_id, project_user_id, role, UNIQUE (project_user_id, project_id))
subproject_roles (subproject_role_id PRIMARY KEY, subproject_id, project_user_id, role, UNIQUE (project_user_id, subproject_id))
place_roles (place_role_id PRIMARY KEY, place_id, project_user_id, role, UNIQUE (project_user_id, place_id))
-- all three role tables:
-- project_user_id REFERENCES project_users ON DELETE CASCADE
-- role user_roles_enum NOT NULLUuid primary keys everywhere — the optimistic-write/operation-queue/revert machinery keys on row ids; email is a join key, never a primary key.
No name column: collaborators are identified and displayed by email (labels are email-based today too); display names for logged-in users come from the auth users table. If ever needed, a nullable name can be added later (non-breaking).
All guarded with WHEN (pg_trigger_depth() < 1) and skipped when electric.syncing (as today):
projectsinsert owner trigger: insert aproject_usersrow with the account owner's auth email (claim it immediately: setauth_user_id) plus aproject_rolesrow with roleown.- Email normalization on
project_userswrites:trim(lower(email)), so every email comparison (login match, sync shapes) is exact. - Role cascade (replaces items 7/9): setting a general role copies it into the lower
*_rolestables; setting a-specificrole removes lower rows; inserting a subproject/place copies roles from the parent scope. Symmetric because all role tables share the same shape. - Label triggers: denormalize
email (role)labels onto the*_rolesrows from the directory, so list views don't join. Editing a person's email in the directory refreshes labels everywhere (replacesusers_*_label_trigger).
- On login (or app start), match the authenticated email against
project_users.email(normalized). Recommended: stampauth_user_idonto matched directory rows (claim), making the identity link a stored fact instead of a repeated string match. Claiming also survives later email mismatches until re-claimed. - Sync shapes use the same resolution, e.g.:
project_users:WHERE project_id IN (SELECT ... matched for this auth user)— the auth user's visible projects*_roles/ project data: viaproject_user_id IN (SELECT project_user_id FROM project_users WHERE auth_user_id = $1)(or email match pre-claim)
- Server-side permission triggers (
12_writePermissionTriggers.sql) and the client-sidecheckWritePermission.tsresolveemail -> project_user_id -> roleand check the role at the target scope or an ancestor.projectsINSERT stays account-owner-only.
- User pickers inside a project list only that project's
project_users(directory). "Add user" creates a directory row with just the email — no globalusersrow is created for collaborators. - The three role forms (project/subproject/place) edit
*_rolesrows; operations queue/revert targets their PKs. - The
usersroute/list shrinks to account/auth concerns (own user, own accounts). - Role options: all six roles remain visible (Owner renders for owner rows); only triggers can set
own.
| current | rebuild |
|---|---|
users (global, exposed) |
users = auth only; project_users = per-project directory |
project_users (user_id + role) |
project_users (directory) + project_roles |
subproject_users |
subproject_roles |
place_users |
place_roles |
place_users.label (from users) |
label triggers off project_users |
| AddUserButton inserts into users | AddUserButton inserts into project_users |
| login = users.user_id | login -> claim (project_users.auth_user_id) |
- Claim-on-login: stamp
auth_user_id(recommended) vs pure email matching on every resolution. Decide handling of auth-email changes (re-claim flow). accounts.email: explicit billing-contact column (may differ from the owner's login email) vs derived. Recommended: explicit, defaulting to the owner's email.- Pending-invite state: do directory rows need a state column (invited/active) before the person first logs in?
- we want the user to find documentation on the /docs route
- we will also link to docs from many pages inside the app
- a docs url should be: /docs/{doc title slugified to lowercase, spaces to -}
- this route needs no login nor database thus it will always load very fast
- for navigation we have a symbol-button in the app header (home: left of enter, data: left of online). It links to /docs
- metadata for docs is stored in an array of objects in a metadata.ts file with these keys: 1. id: {doc title slugified to lowercase, spaces to -} 2. label (= title) 3. order 4. isTechnical
- the menu list is on the left (similar to the nav tree in /data). It can be filtered by: all, contentual, technical (these three are mutually exclusive - only one can be choosen, default is all), text (label)
- docs are rendered on the right
- under 1000px view width ony the menu list is rendered (/docs) or the doc (/docs/doc-id)
- give the 1000px some wiggle to prevent the ui from jumping back and forth near this width value
- Doc sources live in two subfolders of
docs/:docs/docsMd/— docs written in Markdown (converted to HTML by the build script)docs/docsHtml/— docs written directly in HTML (copied as-is by the build script)
- A build script (
scripts/build-docs.mjs, run vianpm run docs:build) combines both source folders intodocs/docs/— do not edit files indocs/docs/manually - The app imports the pre-built
.htmlfragments — no runtime Markdown parsing, docs render instantly - Shared assets (metadata, CSS) live directly in
docs/ - there exists standard css to style docs similarly and simplify their creation. Things prestyled could be: ol, ul, p, h1, h2, h3. We can add to this later when we use it
- docs will not be added in the ui but by devs in dev mode. Thus they need not editing functionality
- TODO: links in docs should always open in a new tab
- docs need to exist in de, en, fr and it. They will be written in either de or en. the writer creates the four files, adds the language to the file name. The metadata contains the label in four versions, fallback is label_de (later could be added to the docs:build script?)
Create scripts/build-docs.mjs:
- Clears
docs/docs/. - Reads every
docs/docsMd/*.mdfile, converts each to an HTML fragment usingmarkedwith a custom renderer that addstarget="_blank"to every<a>tag, writes todocs/docs/{id}.html. - Copies every
docs/docsHtml/*.htmlfile intodocs/docs/, post-processing each to addtarget="_blank"to every<a>tag that doesn't already have atargetattribute.
Convention: when writing HTML docs in docs/docsHtml/, always add target="_blank" to links manually so the source is readable without running the script.
Add to package.json scripts:
"docs:build": "node scripts/build-docs.mjs"Create docs/docs.css with pre-styled base elements:
.doc h1 { … }
.doc h2 { … }
.doc p { … }
.doc ol, .doc ul { … }
/* etc. */Use a .doc wrapper class so styles are scoped and don't bleed into the app shell.
Verify: running npm run docs:build with empty docs/docsMd/ and docs/docsHtml/ directories exits without errors and docs/docs/ is empty.
For each new doc:
- Write the source in
docs/docsMd/{id}_de.md(Markdown) ordocs/docsHtml/{id}_de.html(HTML) - Add
docs/docsHtml/{id}_en.html,docs/docsHtml/{id}_fr.htmlanddocs/docsHtml/{id}_it.html - Add an entry to
docs/metadata.ts - Run
npm run docs:build - Commit the source file, the generated
docs/docs/{id}.html, and the updateddocs/metadata.ts