mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-13 21:27:53 +02:00
So reusers can understand the relationship between tables Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
279 lines
12 KiB
Markdown
279 lines
12 KiB
Markdown
# Database schema description
|
|
|
|
Docs uses a PostgreSQL database used by a Django app (app label `core`,
|
|
project `impress`). All app tables are prefixed `impress_`.
|
|
|
|
## Entity-relationship diagram
|
|
|
|
Core relations (the `impress_user_reconciliation*` admin/ops tables are omitted
|
|
here for clarity — they just hold two nullable FKs to `impress_user`, see below).
|
|
|
|
```mermaid
|
|
erDiagram
|
|
impress_user {
|
|
uuid id PK
|
|
varchar email
|
|
varchar admin_email
|
|
bool is_staff
|
|
bool is_active
|
|
}
|
|
impress_document {
|
|
uuid id PK
|
|
varchar title
|
|
varchar path
|
|
int depth
|
|
varchar link_reach
|
|
varchar link_role
|
|
uuid creator_id FK
|
|
uuid duplicated_from_id FK
|
|
timestamptz deleted_at
|
|
timestamptz ancestors_deleted_at
|
|
}
|
|
impress_document_access {
|
|
uuid id PK
|
|
uuid document_id FK
|
|
uuid user_id FK
|
|
varchar team
|
|
varchar role
|
|
}
|
|
impress_link_trace {
|
|
uuid id PK
|
|
uuid document_id FK
|
|
uuid user_id FK
|
|
}
|
|
impress_document_favorite {
|
|
uuid id PK
|
|
uuid document_id FK
|
|
uuid user_id FK
|
|
}
|
|
impress_document_ask_for_access {
|
|
uuid id PK
|
|
uuid document_id FK
|
|
uuid user_id FK
|
|
varchar role
|
|
}
|
|
impress_invitation {
|
|
uuid id PK
|
|
uuid document_id FK
|
|
uuid issuer_id FK
|
|
varchar email
|
|
varchar role
|
|
}
|
|
impress_thread {
|
|
uuid id PK
|
|
uuid document_id FK
|
|
uuid creator_id FK
|
|
uuid resolved_by_id FK
|
|
bool resolved
|
|
}
|
|
impress_comment {
|
|
uuid id PK
|
|
uuid thread_id FK
|
|
uuid user_id FK
|
|
jsonb body
|
|
}
|
|
impress_comment_reaction {
|
|
uuid id PK
|
|
uuid comment_id FK
|
|
varchar emoji
|
|
}
|
|
|
|
impress_user ||--o{ impress_document : creates
|
|
impress_document ||--o{ impress_document : duplicated_from
|
|
impress_document ||--o{ impress_document_access : has
|
|
impress_user ||--o{ impress_document_access : granted_on
|
|
impress_document ||--o{ impress_link_trace : visited_via_link
|
|
impress_user ||--o{ impress_link_trace : visits
|
|
impress_document ||--o{ impress_document_favorite : starred
|
|
impress_user ||--o{ impress_document_favorite : stars
|
|
impress_document ||--o{ impress_document_ask_for_access : requested_on
|
|
impress_user ||--o{ impress_document_ask_for_access : requests
|
|
impress_document ||--o{ impress_invitation : invites_to
|
|
impress_user ||--o{ impress_invitation : issues
|
|
impress_document ||--o{ impress_thread : has
|
|
impress_user ||--o{ impress_thread : starts
|
|
impress_thread ||--o{ impress_comment : contains
|
|
impress_user ||--o{ impress_comment : writes
|
|
impress_comment ||--o{ impress_comment_reaction : has
|
|
impress_comment_reaction }o--o{ impress_user : reacted_by
|
|
```
|
|
|
|
Note: `impress_document`'s self-relationship shown above (`duplicated_from`) is a
|
|
real FK, but the tree parent/child structure is **not** a FK at all — it's encoded
|
|
in the `path` column (materialized path), so no line for "parent document" appears
|
|
in this diagram even though the hierarchy is central to how documents work (see
|
|
below).
|
|
|
|
## Global conventions
|
|
|
|
- Every app table has: `id UUID PRIMARY KEY` (random uuid4, not sequential),
|
|
`created_at TIMESTAMPTZ`, `updated_at TIMESTAMPTZ`.
|
|
- Foreign keys are UUIDs referencing the `id` of the target table.
|
|
- Enum-like fields are plain `varchar` with values constrained at the application
|
|
layer (Django `TextChoices`), not by a Postgres `CHECK`/`ENUM` type unless noted.
|
|
- **Document body/text content is NOT stored in Postgres.** `Document` rows hold
|
|
metadata only; the actual rich-text/markdown content lives in S3-compatible
|
|
object storage keyed by `{document_id}/file`. Don't expect to grep document text
|
|
via SQL.
|
|
- Besides the app tables below, the database also has standard Django/library
|
|
tables you'll rarely need: `django_migrations`, `django_session`, `django_site`,
|
|
`django_content_type`, `auth_permission`, `auth_group`, `auth_group_permissions`,
|
|
`impress_user_groups`, `impress_user_user_permissions`, `waffle_*` (feature
|
|
flags), `django_admin_log`. Ignore these unless a query is specifically about
|
|
permissions/flags/sessions.
|
|
|
|
## Enums (application-level, stored as text)
|
|
|
|
**role** (`RoleChoices`) — ordered from least to most privileged, used on
|
|
`impress_document_access`, `impress_document_ask_for_access`, `impress_invitation`:
|
|
`reader` < `commenter` < `editor` < `administrator` < `owner`.
|
|
|
|
**link_reach** (`LinkReachChoices`), on `impress_document.link_reach`:
|
|
`restricted` (only users with explicit access) < `authenticated` (any logged-in
|
|
user) < `public` (anyone, incl. anonymous).
|
|
|
|
**link_role** (`LinkRoleChoices`), on `impress_document.link_role` — the role
|
|
granted to whoever satisfies `link_reach`: `reader` < `commenter` < `editor`.
|
|
|
|
## Tables
|
|
|
|
### `impress_user`
|
|
One row per human/service user (OIDC-based auth, no local passwords).
|
|
- `sub` (varchar, unique, nullable) — OIDC subject identifier.
|
|
- `full_name`, `short_name` (varchar, nullable)
|
|
- `email` (varchar, nullable) — identity email from the OIDC token.
|
|
- `admin_email` (varchar, unique, nullable) — separate email used for Django admin login.
|
|
- `language`, `timezone` (varchar)
|
|
- `is_device` (bool) — true for machine/device accounts rather than real users.
|
|
- `is_staff`, `is_active`, `is_superuser` (bool)
|
|
- `is_first_connection` (bool) — onboarding flag.
|
|
|
|
### `impress_document`
|
|
The core object: a page/pad in a folder-like tree (django-treebeard **materialized
|
|
path**, not adjacency-list/recursive CTE).
|
|
- `title` (varchar, nullable), `excerpt` (varchar 300, nullable)
|
|
- `link_reach`, `link_role` — see enums above; this is the document's *own* link
|
|
setting, not necessarily what applies (see "Permission model" below).
|
|
- `creator_id` → `impress_user.id` (nullable, `ON DELETE SET NULL`-like via app logic)
|
|
- `deleted_at` (timestamptz, nullable) — soft-delete marker on the document itself.
|
|
- `ancestors_deleted_at` (timestamptz, nullable) — set when this doc or *any*
|
|
ancestor was soft-deleted; `deleted_at IS NULL AND ancestors_deleted_at IS NOT NULL`
|
|
means "deleted because a parent was deleted", not deleted directly.
|
|
- `has_deleted_children` (bool)
|
|
- `duplicated_from_id` → `impress_document.id` (nullable, self-FK, `SET NULL`)
|
|
- `attachments` (text[] — Postgres array of file keys)
|
|
- Tree columns from django-treebeard (materialized path, base-62-ish alphabet, 7
|
|
chars per path segment):
|
|
- `path` (varchar(252), **unique**, C-collation) — e.g. a child of `path='0000001'`
|
|
is something like `path='00000010000001'`. **Ancestors of a row are found by
|
|
prefix-matching `path`**: `WHERE document.path = LEFT(:child_path, LENGTH(document.path))`
|
|
or, for all ancestors of a node with path `p`: rows whose path is a prefix of `p`.
|
|
Descendants: `WHERE path LIKE p || '%'` (excluding `p` itself for strict descendants).
|
|
- `depth` (int) — 1 = root document.
|
|
- `numchild` (int) — number of *non-deleted* direct children.
|
|
|
|
A row with `depth = 1` is a "workspace root" document (no parent).
|
|
|
|
### `impress_document_access`
|
|
Grants a `role` on a document to either a user OR a team (never both, never
|
|
neither — enforced by a CHECK constraint).
|
|
- `document_id` → `impress_document.id`
|
|
- `user_id` → `impress_user.id` (nullable)
|
|
- `team` (varchar, blank if unused) — an external team identifier string, not an FK
|
|
(teams aren't modeled as a table here; they come from the identity provider).
|
|
- `role` — see role enum.
|
|
- Unique on `(user_id, document_id)` when `user_id IS NOT NULL`, and unique on
|
|
`(team, document_id)` when `team != ''`.
|
|
|
|
**Permission model / access inheritance**: a user's effective role on a document
|
|
is the MAX (by priority) of:
|
|
1. Roles granted directly on that document via `impress_document_access`, and
|
|
2. Roles granted on any of its *ancestors* (same table, matched via the `path`
|
|
prefix trick above), and
|
|
3. The role implied by `link_reach`/`link_role` if the effective `link_reach`
|
|
(own or inherited from ancestors, taking the most permissive) allows it.
|
|
|
|
So "who can access document X" is never a single-table query — it requires
|
|
walking ancestors by path prefix.
|
|
|
|
### `impress_link_trace`
|
|
Records that a given user has visited a document via a share link (used so it
|
|
shows up in their "shared with me" list even without an explicit access row).
|
|
- `document_id`, `user_id` — unique together.
|
|
|
|
### `impress_document_favorite`
|
|
User-starred documents. `document_id`, `user_id` — unique together.
|
|
|
|
### `impress_document_ask_for_access`
|
|
A pending request from a user to be granted access to a document.
|
|
- `document_id`, `user_id` (unique together), `role` (requested role).
|
|
|
|
### `impress_invitation`
|
|
A pending invite by email (for people without an account yet) to get a role on
|
|
a document once they sign up.
|
|
- `email` (varchar), `document_id`, `role`, `issuer_id` → `impress_user.id`.
|
|
- Unique on `(email, document_id)`.
|
|
- Time-limited (`INVITATION_VALIDITY_DURATION` setting); expired invitations are
|
|
effectively ignored by the app even though the row remains until cleanup.
|
|
|
|
### `impress_thread`
|
|
A comment thread anchored to a document.
|
|
- `document_id` → `impress_document.id`
|
|
- `creator_id` → `impress_user.id` (nullable, `SET NULL`)
|
|
- `resolved` (bool), `resolved_at` (timestamptz, nullable), `resolved_by_id` →
|
|
`impress_user.id` (nullable)
|
|
- `metadata` (jsonb)
|
|
|
|
### `impress_comment`
|
|
A single comment within a thread.
|
|
- `thread_id` → `impress_thread.id`
|
|
- `user_id` → `impress_user.id` (nullable, `SET NULL`, i.e. author may be gone)
|
|
- `body` (jsonb) — rich text body of the comment (this one IS stored in Postgres,
|
|
unlike document content).
|
|
- `metadata` (jsonb)
|
|
|
|
### `impress_comment_reaction`
|
|
One row per (comment, emoji) pair; the set of users who reacted with that emoji
|
|
is a many-to-many.
|
|
- `comment_id` → `impress_comment.id`
|
|
- `emoji` (varchar)
|
|
- Unique on `(comment_id, emoji)`.
|
|
- M2M join table **`impress_comment_reaction_users`** with columns
|
|
`reaction_id` → `impress_comment_reaction.id` and `user_id` → `impress_user.id`
|
|
(standard Django auto-generated M2M table, also has its own `id` PK).
|
|
|
|
### `impress_user_reconciliation`
|
|
Admin/ops tool for merging two user accounts (e.g. after an email change).
|
|
- `active_email`, `inactive_email` (varchar)
|
|
- `active_email_checked`, `inactive_email_checked` (bool)
|
|
- `active_user_id`, `inactive_user_id` → `impress_user.id` (nullable)
|
|
- `active_email_confirmation_id`, `inactive_email_confirmation_id` (uuid, unique)
|
|
- `source_unique_id` (varchar, nullable)
|
|
- `status` (varchar: `pending` | `ready` | `done` | `error`)
|
|
- `logs` (text)
|
|
|
|
### `impress_user_reconciliation_csv_import`
|
|
Batch import job for the above.
|
|
- `file` (varchar — storage path)
|
|
- `status` (varchar: `pending` | `running` | `done` | `error`)
|
|
- `logs` (text)
|
|
|
|
## Common query gotchas
|
|
|
|
- **Never** join on `path` with `=`; it's a materialized-path prefix scheme, so
|
|
ancestor/descendant lookups need prefix matching (`LEFT()`, `LIKE`, or
|
|
`starts_with()`), not equality, except when matching the exact same node.
|
|
- To list a document's ancestors including itself: rows whose `path` is a prefix
|
|
of the target's `path` (i.e. `target.path` starts with `candidate.path`), among
|
|
rows with `ancestors_deleted_at IS NULL`.
|
|
- To list descendants: `path LIKE (target_path || '%')`.
|
|
- "Is this document visible/deleted" needs both `deleted_at` (deleted directly)
|
|
and `ancestors_deleted_at` (deleted via an ancestor) — a document can be
|
|
non-null on the latter without being null on the former.
|
|
- Role comparisons are by priority order, not alphabetically:
|
|
`reader(1) < commenter(2) < editor(3) < administrator(4) < owner(5)`.
|
|
- `impress_document_access.user_id` and `.team` are mutually exclusive — don't
|
|
assume `user_id` is always populated.
|
|
- Document body text is not in Postgres at all — don't attempt full-text SQL
|
|
search over document content; only `title`/`excerpt` are queryable that way.
|