Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c150acf71d | ||
|
|
84b5a250e2 | ||
|
|
187681a36f | ||
|
|
dab8915375 | ||
|
|
61c876edec | ||
|
|
3e964d1176 | ||
|
|
3f6dff5f24 | ||
|
|
708da6b9f7 | ||
|
|
c69eccb5c1 | ||
|
|
a382fa904a | ||
|
|
732d4d8931 |
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
coverage
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Copy to .env and adjust. All settings are optional for local development.
|
||||||
|
|
||||||
|
# Database. Defaults to embedded PostgreSQL (PGlite) stored in ./data/pglite.
|
||||||
|
# DATABASE_URL=postgres://safetylog:password@localhost:5432/safetylog
|
||||||
|
# DATABASE_URL=pglite://./data/pglite
|
||||||
|
|
||||||
|
# Port for the API server (and the web app in production).
|
||||||
|
# PORT=3000
|
||||||
|
|
||||||
|
# The URL people open in their browser. Use https:// in production so session cookies are marked Secure.
|
||||||
|
# PUBLIC_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Email (sign-in links, password resets, invitations, notifications). Without it, emails are
|
||||||
|
# printed to the server log in development and dropped in production.
|
||||||
|
# SMTP_URL=smtps://user:password@smtp.example.com:465
|
||||||
|
# MAIL_FROM=Open SafetyLog EHS <safety@example.com>
|
||||||
|
|
||||||
|
# Time zone used for due dates and daily reminders, e.g. America/Chicago.
|
||||||
|
# TZ=UTC
|
||||||
|
|
||||||
|
# Where uploaded photos are stored, and the per-file size limit.
|
||||||
|
# UPLOAD_DIR=./data/uploads
|
||||||
|
# MAX_UPLOAD_MB=10
|
||||||
|
|
||||||
|
# Docker Compose only: password for the bundled PostgreSQL container.
|
||||||
|
# Use letters and numbers only (it is embedded in a connection URL).
|
||||||
|
# POSTGRES_PASSWORD=change-me
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Lint, typecheck, test, build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
name: End-to-end tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Keep this tag in sync with the @playwright/test version in package-lock.json
|
||||||
|
container: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test:e2e
|
||||||
|
env:
|
||||||
|
HOME: /root
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report
|
||||||
|
retention-days: 7
|
||||||
@@ -22,3 +22,16 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
blob-report
|
||||||
|
|
||||||
|
# Local data and config
|
||||||
|
data
|
||||||
|
.env
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
"plugins": ["react", "oxc"],
|
"plugins": ["react", "typescript", "oxc", "jsx-a11y", "vitest"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"react/rules-of-hooks": "error",
|
"react/rules-of-hooks": "error",
|
||||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
"react/only-export-components": ["warn", { "allowConstantExport": true }],
|
||||||
}
|
"jsx-a11y/control-has-associated-label": "off",
|
||||||
|
"jsx-a11y/prefer-tag-over-role": "off",
|
||||||
|
"jsx-a11y/label-has-associated-control": ["error", { "depth": 4 }]
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["dist", "playwright-report", "test-results"]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at jay@equineform.com. All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Contributing to Open SafetyLog EHS
|
||||||
|
|
||||||
|
Thanks for helping make workplace safety software free for everyone. Contributions of all kinds are welcome: bug reports, feature ideas, documentation, design, translations and code.
|
||||||
|
|
||||||
|
Everyone taking part is expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
## Ways to contribute
|
||||||
|
|
||||||
|
- **Report a bug.** Open an issue with steps to reproduce, what you expected, what happened, and your browser and device.
|
||||||
|
- **Suggest a feature.** Open an issue describing the safety problem you're trying to solve, not only the solution. Real-world context from plants and safety teams is especially valuable.
|
||||||
|
- **Pick up work.** Check the [roadmap](docs/ROADMAP.md) and [to-do list](docs/TODO.md). Comment on an issue before starting anything large so we can agree on the approach.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
Requires Node.js 22.18 or later (see `.nvmrc`). No database install needed: development uses embedded PostgreSQL in `./data/`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npx playwright install chromium # once, for end-to-end tests
|
||||||
|
npm run db:seed # optional demo data
|
||||||
|
npm run dev # web app :5173, API :3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Before opening a pull request, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run check # lint, typecheck, unit tests, build
|
||||||
|
npm run test:e2e # end-to-end tests on desktop and mobile
|
||||||
|
```
|
||||||
|
|
||||||
|
CI runs the same checks on every pull request.
|
||||||
|
|
||||||
|
## Code guidelines
|
||||||
|
|
||||||
|
- **TypeScript** everywhere; avoid `any`.
|
||||||
|
- **Structure:** routes go in `src/pages/`, reusable UI in `src/components/`, and types and domain logic in `src/lib/` (no React components there). API routes go in `server/routes/`, one file per resource. Enums and request schemas used by both sides live in `shared/`.
|
||||||
|
- **Server code runs without a build step** (Node strips the types), so use only erasable TypeScript: no `enum`, `namespace` or constructor parameter properties, and include the `.ts` extension in relative imports. `npm run typecheck` enforces this.
|
||||||
|
- **Database changes:** edit `server/db/schema.ts`, then run `npm run db:generate` and commit the generated migration. Migrations run automatically when the server starts.
|
||||||
|
- **Permissions:** every API route must check the signed-in user's role and organization. Add a test showing a lower role is refused.
|
||||||
|
- **Styling:** Tailwind CSS. Use the theme tokens in `src/index.css` (`text-ink`, `bg-coral`, `border-line`, …) rather than new hex values; add a token if a color is reused.
|
||||||
|
- **Tests:** put domain logic in `src/lib/` with a unit test next to it (`*.test.ts`). Cover user-facing flows with a component test or a Playwright test in `e2e/`.
|
||||||
|
- **Translations:** user-facing text on worker screens lives in `src/i18n/en.ts` with the Spanish in `src/i18n/es.ts`. TypeScript and a test fail if a Spanish entry is missing, so add both together. If you can't translate, open the pull request anyway and ask for help.
|
||||||
|
- **Accessibility:** every form control needs a label, and every icon-only button needs an `aria-label`. Shop-floor users may be on gloves-on touchscreens, so keep touch targets generous.
|
||||||
|
- **Safety data:** incident and injury records can contain medical and personal information. Never log them, and never add third-party scripts, fonts or analytics that would send data off the server.
|
||||||
|
|
||||||
|
## Commits and pull requests
|
||||||
|
|
||||||
|
- Branch from `main` and keep each pull request focused on one change.
|
||||||
|
- Use [Conventional Commits](https://www.conventionalcommits.org/) messages, e.g. `feat: add QR code location labels` or `fix: keep filter when returning to list`.
|
||||||
|
- Describe what changed and why, link related issues, and add screenshots for UI changes (desktop and mobile).
|
||||||
|
- Update `docs/ROADMAP.md` or `docs/TODO.md` if your change completes or adds an item.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Open SafetyLog EHS is licensed under the [GNU Affero General Public License v3.0](LICENSE). By submitting a contribution, you agree that it is licensed under the same terms.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# --- Build the web app ---
|
||||||
|
FROM node:24-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Runtime: API server + built web app ---
|
||||||
|
FROM node:24-alpine
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
PORT=3000 \
|
||||||
|
DATABASE_URL=pglite:///app/data/pglite
|
||||||
|
WORKDIR /app
|
||||||
|
# Time-zone data so TZ (e.g. America/Chicago) sets local days for due dates and reminders
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
COPY server ./server
|
||||||
|
COPY shared ./shared
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
RUN mkdir -p /app/data && chown node:node /app/data
|
||||||
|
USER node
|
||||||
|
VOLUME /app/data
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
|
||||||
|
CMD ["node", "server/index.ts"]
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <http://www.gnu.org/licenses/>.
|
||||||
@@ -1,16 +1,111 @@
|
|||||||
# React + Vite
|
# Open SafetyLog EHS
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
> **Status: early development (Phases 0–5 done).** Reporting (including offline, kiosk tablets, QR codes and Spanish), investigation, corrective actions, OSHA recordkeeping (Forms 300, 300A, 301, rates and electronic submission files), leading/lagging-indicator analytics with saved views, and email notifications work end to end. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items.
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|

|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
## Screenshots
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
Taken with the demo data from `npm run db:seed`, signed in as the safety manager.
|
||||||
|
|
||||||
## Expanding the Oxlint configuration
|
| Incident register | Incident workflow |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
| **OSHA Form 300 log** | **Analytics** |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
<p align="center"><img src="docs/screenshots/report-mobile.png" width="300" alt="Report an incident form on a phone"></p>
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
Requires Node.js 22.18 or later (see `.nvmrc`). No database install is needed for development: the server uses embedded PostgreSQL (PGlite) stored in `./data/`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run db:seed # optional: demo organization, sites, users and incidents
|
||||||
|
npm run dev # web app on http://localhost:5173, API on http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run db:seed` also loads a year of realistic history so the dashboards and OSHA log have data. Demo users: `admin@example.com`, `manager@example.com`, `supervisor@example.com` and `reporter@example.com`, all with password `safetylog-demo`. Without seeding, opening the app takes you to first-run setup.
|
||||||
|
|
||||||
|
In development, emails (sign-in links, invitations, notifications) are printed in the terminal running the server.
|
||||||
|
|
||||||
|
Settings are read from environment variables or a `.env` file; see [.env.example](.env.example).
|
||||||
|
|
||||||
|
## Running in production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env # set POSTGRES_PASSWORD, and PUBLIC_URL if not localhost
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the app and PostgreSQL. The app applies database migrations on startup and serves the web app and API on port 3000. Uploaded photos are kept in the `app-data` volume.
|
||||||
|
|
||||||
|
- Use HTTPS (for example behind a reverse proxy) and set `PUBLIC_URL` to the `https://` address so session cookies are marked secure and email links point to the right place.
|
||||||
|
- Set `SMTP_URL` and `MAIL_FROM` so sign-in links, invitations and notifications are delivered.
|
||||||
|
- Set `TZ` to the plant's time zone (e.g. `America/Chicago`) so due dates and daily reminders follow local days.
|
||||||
|
- Back up both volumes: `db-data` (PostgreSQL) and `app-data` (uploads).
|
||||||
|
- Installing the app on phones and opening it offline needs HTTPS (browsers require it for service workers). Over plain HTTP everything else works, and reports made while offline are still queued once the page is open.
|
||||||
|
|
||||||
|
## OSHA recordkeeping
|
||||||
|
|
||||||
|
On an incident, safety managers add each injured or ill person and answer a short set of questions; the app decides whether the case is recordable under 29 CFR 1904 and how it's classified, flags injuries that must be reported to OSHA within 8 or 24 hours, and builds the Form 300 Log, 300A Summary and 301 reports per site. Enter monthly hours and headcount for incidence rates (TRIR, DART), add establishment details on the Locations page, and download the CSV files for OSHA's Injury Tracking Application from **OSHA log → Submit to OSHA**.
|
||||||
|
|
||||||
|
The app helps apply OSHA's rules consistently, but it is not legal advice: the employer remains responsible for its records.
|
||||||
|
|
||||||
|
## Shop-floor reporting
|
||||||
|
|
||||||
|
- **Kiosk tablets and QR codes:** in **Settings → Reporting links**, create a link. Open it on a shared tablet for a kiosk, or print QR codes for it from **Locations → QR codes**. Anyone can then report without an account; the link can't read anything and can be revoked at any time.
|
||||||
|
- **Offline:** reports made without a connection are kept on the device and sent automatically when it returns.
|
||||||
|
- **Spanish:** use the language button (sidebar, sign-in page or kiosk header). The choice is remembered on that device.
|
||||||
|
|
||||||
|
For a very small single-machine install, the app image also runs on its own with embedded PostgreSQL: `docker run -p 3000:3000 -v safetylog-data:/app/data <image>`.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run dev` | Start the web app and API together with auto-reload |
|
||||||
|
| `npm start` | Start the API server (serves `dist/` too when built) |
|
||||||
|
| `npm run build` | Production build to `dist/` |
|
||||||
|
| `npm run lint` | Lint with Oxlint (warnings fail) |
|
||||||
|
| `npm run typecheck` | Type-check with TypeScript |
|
||||||
|
| `npm test` | Web and server tests (Vitest; server tests use an in-memory database) |
|
||||||
|
| `npm run db:generate` | Create a migration after changing `server/db/schema.ts` |
|
||||||
|
| `npm run db:seed` | Load demo data into an empty database |
|
||||||
|
| `npm run test:e2e` | End-to-end tests (Playwright, desktop and mobile). Run `npx playwright install chromium` once first |
|
||||||
|
| `npm run check` | Lint, typecheck, test and build in one go |
|
||||||
|
|
||||||
|
CI runs the same checks on every push and pull request (`.gitea/workflows/ci.yml`).
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ Web app (React)
|
||||||
|
pages/ One component per route
|
||||||
|
components/ Shared UI (layout, tables, form pieces) and shared class strings in styles.ts
|
||||||
|
lib/ Types, domain logic, demo data; no React components
|
||||||
|
index.css Tailwind import and design tokens (@theme)
|
||||||
|
server/ API (Hono on Node)
|
||||||
|
routes/ One file per resource, with its tests alongside
|
||||||
|
auth/ Passwords, sessions, permissions
|
||||||
|
db/ Drizzle schema, migrations, seed script
|
||||||
|
shared/ Enums and validation schemas used by both
|
||||||
|
e2e/ Playwright tests
|
||||||
|
docs/ Roadmap, to-do list, API reference
|
||||||
|
```
|
||||||
|
|
||||||
|
Styling is Tailwind CSS v4. Colors, fonts and small text sizes are defined as theme tokens in `src/index.css`. Fonts are bundled through Fontsource, so the app makes no third-party requests and works on isolated plant networks.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md). Everyone taking part follows our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[GNU Affero General Public License v3.0](LICENSE). If you run a modified version as a network service, you must offer its source code to its users. The app's About page links to the source; set `VITE_SOURCE_URL` at build time to point it at your fork.
|
||||||
|
|
||||||
|
## Contact Info
|
||||||
|
Please contact me, Jay Jennings, at jay@equineform.com if you need assistance with site setup and configuration. I am also open to custom builds when you have special requirements or ideas that might better serve the safety community.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Production setup: the app plus PostgreSQL.
|
||||||
|
# cp .env.example .env # set POSTGRES_PASSWORD (and PUBLIC_URL if not localhost)
|
||||||
|
# docker compose up -d
|
||||||
|
# Then open http://localhost:3000 and complete first-run setup.
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: safetylog
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||||
|
POSTGRES_DB: safetylog
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U safetylog -d safetylog"]
|
||||||
|
interval: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://safetylog:${POSTGRES_PASSWORD}@db:5432/safetylog
|
||||||
|
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000}
|
||||||
|
SMTP_URL: ${SMTP_URL:-}
|
||||||
|
MAIL_FROM: ${MAIL_FROM:-Open SafetyLog EHS <no-reply@localhost>}
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
|
volumes:
|
||||||
|
- app-data:/app/data # uploaded photos and files
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
app-data:
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# API reference
|
||||||
|
|
||||||
|
JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in, emailed links and password changes.
|
||||||
|
|
||||||
|
Request bodies are validated against [`shared/schemas.ts`](../shared/schemas.ts); response shapes are in [`shared/api-types.ts`](../shared/api-types.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`.
|
||||||
|
|
||||||
|
Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manager` < `admin`. A role can do everything the roles below it can.
|
||||||
|
|
||||||
|
Browsers must send an `Origin` header matching the site on form/multipart and body-less requests (all browsers do); JSON requests are protected by CORS.
|
||||||
|
|
||||||
|
## Setup and authentication
|
||||||
|
|
||||||
|
| Method | Path | Who | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/health` | anyone | `{ ok: true }` |
|
||||||
|
| GET | `/api/setup` | anyone | `{ needsSetup }`: true until the first admin exists |
|
||||||
|
| POST | `/api/setup` | anyone, once | `{ organizationName, name, email, password }`. Creates the organization and an admin, signs them in. 409 afterwards |
|
||||||
|
| POST | `/api/auth/login` | anyone | `{ email, password }`. 401 on bad credentials, 429 after 10 attempts per email in 15 minutes |
|
||||||
|
| POST | `/api/auth/magic-link` | anyone | `{ email }`. Emails a single-use sign-in link (15 minutes). Always 204 |
|
||||||
|
| POST | `/api/auth/magic-link/verify` | anyone | `{ token }`. Signs in |
|
||||||
|
| POST | `/api/auth/password-reset` | anyone | `{ email }`. Emails a reset link (1 hour). Always 204 |
|
||||||
|
| POST | `/api/auth/password-reset/confirm` | anyone | `{ token, newPassword }`. Sets the password, signs out other sessions, signs in. Also completes invitations |
|
||||||
|
| POST | `/api/auth/logout` | anyone | Ends the session. 204 |
|
||||||
|
| GET | `/api/auth/me` | signed in | `{ user, organization }` |
|
||||||
|
| POST | `/api/auth/password` | signed in | `{ currentPassword, newPassword }`. Signs out all other sessions. 204 |
|
||||||
|
|
||||||
|
Passwords need at least 10 characters. Email requests are limited to 5 per address per 15 minutes. Emailed links land on web pages (`/auth/magic`, `/auth/reset`) that POST the token, so link scanners in email systems can't use them up.
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
| Method | Path | Who | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/users` | safety_manager | All users in the organization |
|
||||||
|
| GET | `/api/users/assignable` | supervisor | Active users `{ id, name, role }`, for assigning leads and actions |
|
||||||
|
| POST | `/api/users` | admin | `{ name, email, role, password? }`. Without `password` an invitation email is sent (link valid 7 days). Returns `{ user, invited }` |
|
||||||
|
| PATCH | `/api/users/:id` | admin | `{ name?, role?, active?, password? }`. Deactivating or setting a password signs the user out. The last active admin can't be demoted or deactivated |
|
||||||
|
|
||||||
|
## Sites and areas
|
||||||
|
|
||||||
|
| Method | Path | Who | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/sites` | signed in | Sites with their `areas` (flat list; build the tree from `parentId`). `?includeArchived=true` to include archived |
|
||||||
|
| POST | `/api/sites` | safety_manager | `{ name, address?, timezone? }` |
|
||||||
|
| PATCH | `/api/sites/:id` | safety_manager | `{ name?, address?, timezone?, archived? }` |
|
||||||
|
| POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other` |
|
||||||
|
| PATCH | `/api/areas/:id` | safety_manager | `{ name?, kind?, archived? }` |
|
||||||
|
|
||||||
|
## Incidents
|
||||||
|
|
||||||
|
Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps.
|
||||||
|
|
||||||
|
**Visibility:** supervisors and above see every incident. Reporters see incidents they reported plus any where they're assigned a corrective action. Anything else answers 404.
|
||||||
|
|
||||||
|
| Method | Path | Who | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/incidents` | signed in | `{ items, page, pageSize, total }`, newest first |
|
||||||
|
| GET | `/api/incidents/export` | signed in | CSV of the same filters (no paging, up to 10,000 rows) |
|
||||||
|
| POST | `/api/incidents` | signed in | Create a report (below). High/critical reports email safety managers and admins |
|
||||||
|
| GET | `/api/incidents/:number` | signed in | Full detail: investigation, root causes, actions, attachments, the `transitions` you may perform (with `blockedReason` when not yet possible) and `can` flags |
|
||||||
|
| PATCH | `/api/incidents/:number` | supervisor | Any create field except `anonymous`. Status changes use transitions |
|
||||||
|
| POST | `/api/incidents/:number/transitions` | see below | `{ to, note? }`. The note is also posted as a comment |
|
||||||
|
| GET/POST | `/api/incidents/:number/comments` | can see it | POST `{ body }` |
|
||||||
|
| GET | `/api/incidents/:number/timeline` | can see it | Audit entries `{ id, at, actor, action, changes }` |
|
||||||
|
| PUT | `/api/incidents/:number/investigation` | supervisor | `{ leadId?, method?, summary?, completed? }`. Creates the investigation if needed |
|
||||||
|
| POST | `/api/incidents/:number/root-causes` | supervisor | `{ category, description, whys? }` |
|
||||||
|
| POST | `/api/incidents/:number/actions` | supervisor | `{ title, description?, kind?, assigneeId?, dueDate?, rootCauseId? }`. Not on closed incidents. Emails the assignee |
|
||||||
|
| POST | `/api/incidents/:number/attachments` | can see it | Multipart, field `file`. Photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs only, detected from content; `MAX_UPLOAD_MB` each (default 10), 20 per incident |
|
||||||
|
|
||||||
|
**List and export filters:** `page`, `pageSize` (1–100, default 25), `q` (title, description, location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`.
|
||||||
|
|
||||||
|
Send a random `clientId` (UUID) with each report. Resubmitting the same `clientId` returns the original report (200 instead of 201) instead of creating a duplicate, so devices can safely retry after a dropped connection.
|
||||||
|
|
||||||
|
**Create body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Forklift reversed into pallet stack",
|
||||||
|
"type": "near_miss",
|
||||||
|
"severity": "high",
|
||||||
|
"occurredAt": "2026-10-06T08:15:00-05:00",
|
||||||
|
"siteId": "…",
|
||||||
|
"areaId": "…",
|
||||||
|
"locationDetail": "Loading bay C",
|
||||||
|
"description": "…",
|
||||||
|
"immediateAction": "Area cordoned off",
|
||||||
|
"anonymous": false,
|
||||||
|
"clientId": "6f1c…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `type`: `near_miss`, `hazard`, `injury`, `illness`, `property_damage`, `environmental`
|
||||||
|
- `severity`: `low`, `medium`, `high`, `critical`
|
||||||
|
- `occurredAt` needs a time-zone offset and can't be in the future. Anonymous reports don't record the reporter at all.
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
`reported` → `triage` → `investigation` → `actions` → `verification` → `closed`. The rules live in [`shared/workflow.ts`](../shared/workflow.ts) and are enforced by the API.
|
||||||
|
|
||||||
|
| From | To | Who | Condition |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| reported | triage | supervisor | |
|
||||||
|
| triage | investigation | supervisor | |
|
||||||
|
| triage | actions | supervisor | |
|
||||||
|
| triage | closed | supervisor | Low or medium severity only |
|
||||||
|
| investigation | actions | supervisor | At least one root cause |
|
||||||
|
| actions | investigation | supervisor | |
|
||||||
|
| actions | verification | supervisor | At least one action, none open or in progress |
|
||||||
|
| verification | actions | safety_manager | |
|
||||||
|
| verification | closed | safety_manager | Every (non-cancelled) action verified |
|
||||||
|
| closed | investigation | safety_manager | Reopen |
|
||||||
|
|
||||||
|
## Root causes, actions, attachments
|
||||||
|
|
||||||
|
| Method | Path | Who | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| PATCH | `/api/root-causes/:id` | supervisor | `{ category?, description?, whys? }` |
|
||||||
|
| DELETE | `/api/root-causes/:id` | supervisor | |
|
||||||
|
| GET | `/api/actions` | signed in | `?scope=mine\|all` (`all` needs supervisor) and `?filter=open\|overdue\|done\|all`. Sorted by due date |
|
||||||
|
| PATCH | `/api/actions/:id` | see notes | Supervisors: any field and status except `verified`. Safety managers: also `verified`. Assignees: only `status` among `open`, `in_progress`, `done` |
|
||||||
|
| GET | `/api/attachments/:id` | can see the incident | The file, with a sandboxing Content-Security-Policy |
|
||||||
|
| DELETE | `/api/attachments/:id` | uploader or supervisor | |
|
||||||
|
|
||||||
|
Each action has `overdue: true` when it's open or in progress and its due date has passed (server time zone, `TZ`). Assignees get one email digest per day listing their actions due tomorrow or overdue.
|
||||||
|
|
||||||
|
## Reporting links (kiosks and QR codes)
|
||||||
|
|
||||||
|
Reporting links let people file reports without an account. Each has a token that can **only** create reports.
|
||||||
|
|
||||||
|
| Method | Path | Who | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/kiosks` | safety_manager | All links, including their tokens (to reprint QR codes) |
|
||||||
|
| POST | `/api/kiosks` | safety_manager | `{ name, siteId? }`. `siteId` restricts the link to one site |
|
||||||
|
| PATCH | `/api/kiosks/:id` | safety_manager | `{ name?, siteId?, revoked? }` |
|
||||||
|
| GET | `/api/kiosk/config` | link token | Organization name, link name, and the sites/areas it may report on |
|
||||||
|
| POST | `/api/kiosk/reports` | link token | Multipart: `report` (JSON: the create body minus `anonymous`, plus optional `reporterName`) and up to 5 `files`. Returns `{ reference, failedFiles }`. 30 reports per link per 10 minutes |
|
||||||
|
|
||||||
|
The token goes in the `X-Kiosk-Token` header. Browsers can't send custom headers cross-site without a CORS preflight, which the API doesn't allow, so other websites can't submit through a link. Reports without `reporterName` are anonymous. Incidents filed this way have `channel: "kiosk"` and `kiosk: { id, name }`.
|
||||||
|
|
||||||
|
The web app opens links at `/k/<token>`, optionally with `?site=<id>&area=<id>` to pre-fill the location (as QR codes do).
|
||||||
|
|
||||||
|
## OSHA recordkeeping
|
||||||
|
|
||||||
|
All endpoints need **safety_manager**: they hold confidential medical information. Other roles only see `injuredCount` on an incident. Rules are in [`shared/osha.ts`](../shared/osha.ts); section numbers refer to 29 CFR 1904.
|
||||||
|
|
||||||
|
| Method | Path | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/osha/incidents/:number/cases` | Injured or ill people on an incident |
|
||||||
|
| POST | `/api/osha/incidents/:number/cases` | `{ name, ...answers }`. Case number is the incident reference, with `-2`, `-3`… for further people |
|
||||||
|
| PATCH | `/api/osha/cases/:id` | Any case field. `recordable` and `outcome` are always recalculated from the answers; days away + restricted may not exceed 180 |
|
||||||
|
| DELETE | `/api/osha/cases/:id` | |
|
||||||
|
| GET | `/api/osha/cases/:id/301` | Data for Form 301 |
|
||||||
|
| GET | `/api/osha/log?siteId=&year=` | 300 Log rows, 300A totals, monthly hours, annual average employees, TRIR/DART/LTIR, ITA checks, missing establishment fields |
|
||||||
|
| GET/PUT | `/api/osha/sites/:id/hours?year=` | Monthly `{ month, hours, averageEmployees }`; `null` for both clears a month |
|
||||||
|
| GET | `/api/osha/ita/summary.csv?year=` | ITA Establishment and 300A file, one row per active site |
|
||||||
|
| GET | `/api/osha/ita/cases.csv?year=` | ITA case data (Forms 300/301) file, without names |
|
||||||
|
|
||||||
|
**Case answers:** `employmentType`, `workRelated`, `newCase`, `died`, `dateOfDeath`, `daysAwayFromWork`, `daysAway`, `restrictedOrTransfer`, `daysRestricted`, `medicalTreatment`, `lossOfConsciousness`, `significantDiagnosis`, `specialCase` (`none`, `needlestick`, `medical_removal`, `hearing_loss`, `tuberculosis`), `caseType` (300 column M), `privacyCase`, severe-injury flags (`inpatientHospitalization`, `amputation`, `lossOfEye`, `reportedToOshaAt`) and the Form 301 fields (`jobTitle`, `natureOfInjury`, `bodyPart`, `objectSubstance`, `activityBefore`, `whatHappened`, `dateOfBirth`, `dateOfHire`, `sex`, `employeeAddress`, `physicianName`, `treatmentFacility`, `treatedInEmergencyRoom`, `timeStartedWork`).
|
||||||
|
|
||||||
|
Responses include `severeReport` (`{ withinHours, reasons, dueBy }`) when 1904.39 requires a direct report, and `missingFields` listing what Form 301 and the ITA still need. A case newly needing a direct report emails safety managers and admins.
|
||||||
|
|
||||||
|
Site establishment fields (`streetAddress`, `city`, `state`, `zip`, `naicsCode`, `industryDescription`, `ein`, `establishmentType`) are set with `PATCH /api/sites/:id`.
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
| Method | Path | Who | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/stats/overview` | signed in | Open incidents, actions due/overdue, average close time, status counts, weekly trend (12 weeks), incidents by site this year |
|
||||||
|
| GET | `/api/stats/analytics` | signed in | `?from=YYYY-MM-DD&to=YYYY-MM-DD` (up to 3 years), optional `siteId`, `type`. Leading/lagging KPIs with the previous period, trend (weekly up to ~3 months, else monthly), locations, root causes, weekday × hour heatmap, actions on time vs late, and for supervisors+ rolling 12-month TRIR/DART and yearly rates |
|
||||||
|
| GET | `/api/views?page=analytics\|incidents` | signed in | Your saved views plus shared ones |
|
||||||
|
| POST | `/api/views` | signed in | `{ page, name, filters, shared }`. Sharing needs supervisor |
|
||||||
|
| DELETE | `/api/views/:id` | owner or admin | |
|
||||||
|
|
||||||
|
Stats only count what the signed-in user can see.
|
||||||
|
|
||||||
|
Every create and update on an incident, its investigation, actions and files is recorded in the audit log with who made it and what changed.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Open SafetyLog EHS Roadmap
|
||||||
|
|
||||||
|
A free, open-source (AGPL-3.0) accident and safety tracking system for manufacturing.
|
||||||
|
|
||||||
|
This is a living document. Update it as decisions are made or scope changes.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Topic | Decision | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| License | AGPL-3.0-only | Decided |
|
||||||
|
| Frontend | React + Vite + TypeScript, Tailwind CSS v4 | Decided |
|
||||||
|
| Backend | Node + Hono, PostgreSQL via Drizzle ORM, Zod schemas shared with the web app (`shared/`). Node runs the TypeScript directly (no build step for the server) | Decided |
|
||||||
|
| Database | PostgreSQL in production; embedded PGlite for local development, tests and very small installs | Decided |
|
||||||
|
| Auth | Email + password (scrypt), server-side sessions in an http-only cookie, four roles: reporter < supervisor < safety manager < admin | Decided |
|
||||||
|
| Deployment | Self-hosted, single `docker compose up` (app + Postgres); file storage added with photo upload | Decided |
|
||||||
|
| Compliance scope | US OSHA first; data model kept extensible for other regions (e.g. RIDDOR) | Proposed |
|
||||||
|
| Incident PDFs | Browser print-to-PDF from a print stylesheet; server-side PDF deferred (see TODO) | Decided |
|
||||||
|
| Email | SMTP via `SMTP_URL`; without it, logged in development and dropped in production. Delivery setup deferred (see TODO) | Decided |
|
||||||
|
| Reporting without accounts | "Reporting links": revocable, rate-limited tokens that can only file reports; used for kiosk tablets and QR codes | Decided |
|
||||||
|
| Languages | English + Spanish with typed message files; worker-facing screens first, manager screens later (see TODO) | Decided |
|
||||||
|
| Offline | Installable PWA; reports queued in IndexedDB and retried with a client-generated id so retries never duplicate | Decided |
|
||||||
|
| OSHA rules | One shared rules module (`shared/osha.ts`) used by API and UI; printable forms are OSHA-permitted equivalents (1904.29(b)(4)); ITA exports follow OSHA's published CSV specs | Decided |
|
||||||
|
| Medical privacy | Injured-person and medical details visible to safety managers and admins only; timeline and case CSV carry no names | Decided |
|
||||||
|
| Charts | Hand-built SVG components (no chart library) following the dataviz method: validated color slots, one axis, hairline grid, hover tooltips, a table view on every chart | Decided |
|
||||||
|
| CI | Gitea Actions (`.gitea/workflows`): lint, typecheck, unit tests, build, e2e | Decided |
|
||||||
|
| Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided |
|
||||||
|
|
||||||
|
## Phase 0: Foundation
|
||||||
|
|
||||||
|
- [x] AGPL-3.0 LICENSE
|
||||||
|
- [x] Convert to TypeScript
|
||||||
|
- [x] Split `App.jsx` into `pages/`, `components/`, `lib/`
|
||||||
|
- [x] Convert styling to Tailwind (remove hand-written `App.css`)
|
||||||
|
- [x] Remove unused template files
|
||||||
|
- [x] Bundle fonts locally (no Google Fonts dependency for offline/locked-down plant networks)
|
||||||
|
- [x] CI: lint, typecheck, unit tests (Vitest), build, e2e (Playwright)
|
||||||
|
- [x] Replace template README
|
||||||
|
- [x] CONTRIBUTING.md and CODE_OF_CONDUCT.md
|
||||||
|
- [x] About page with source-code link (AGPL section 13)
|
||||||
|
- [ ] Remaining setup items (name, CoC contact, final source URL, CI runner): see [TODO.md](TODO.md)
|
||||||
|
|
||||||
|
## Phase 1: Backend and data model
|
||||||
|
|
||||||
|
- [x] Database schema and first migration for all entities: Organization, Site → Area (location tree), User, Session, Incident, InjuredPerson, Attachment, Investigation, RootCause, CorrectiveAction, Comment, AuditLog, HoursWorked
|
||||||
|
- [x] First-run setup (creates the organization and first admin)
|
||||||
|
- [x] Email/password sign-in, sessions, change password, login rate limiting
|
||||||
|
- [x] Role-based permissions (reporters see only their own reports)
|
||||||
|
- [x] REST API: users, sites/areas, incidents with pagination, search, filters and gap-free sequential numbers (`INC-0001`); see [API.md](API.md)
|
||||||
|
- [x] Audit log of every change
|
||||||
|
- [x] Server tests (Vitest + in-memory PostgreSQL), demo seed data
|
||||||
|
- [x] Dockerfile and `docker-compose.yml`
|
||||||
|
- Moved to Phase 2: magic-link sign-in and password reset by email (both need the email sending built for notifications). SSO/OIDC stays "later".
|
||||||
|
|
||||||
|
## Phase 2: Core workflow (MVP)
|
||||||
|
|
||||||
|
- [x] Web app connected to the API: sign-in, first-run setup, every page on live data (demo data removed from the frontend)
|
||||||
|
- [x] Photo and PDF upload, checked by file content (not name), size-limited, served with a sandboxing security policy
|
||||||
|
- [x] Magic-link sign-in, password reset by email, email invitations for new users (admins can also set passwords when there's no email)
|
||||||
|
- [x] Incident detail page: details editing, activity log (comments + audit trail), investigation with 5 Whys and fishbone (6M) views, corrective actions (CAPA)
|
||||||
|
- [x] Status workflow Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role rules and guards (e.g. high severity can't skip investigation; closing needs every action verified), shared between API and UI
|
||||||
|
- [x] "My actions" and "All actions" lists with overdue tracking; assignees can update progress on their own actions
|
||||||
|
- [x] Email notifications: high/critical alerts to safety managers, action assignment, daily due/overdue digest
|
||||||
|
- [x] CSV export of the filtered register (protected against spreadsheet formula injection); print-friendly incident page for PDF via the browser
|
||||||
|
- [x] Overview dashboard and Analytics computed from real data; Locations and Settings (people, roles, passwords) pages
|
||||||
|
- [x] End-to-end tests run against the real server: full three-role lifecycle, sign-in, visibility rules, mobile reporting
|
||||||
|
|
||||||
|
## Phase 3: Shop-floor reporting
|
||||||
|
|
||||||
|
- [x] Installable app (web manifest, icons, service worker caching the app shell plus who-am-I and site lists)
|
||||||
|
- [x] Offline outbox: reports and photos saved on the device, sent automatically on reconnect; duplicate-proof retries via a client-generated id; reports only sent for the user who made them; warning on sign-out with unsent reports
|
||||||
|
- [x] Reporting links (managed in Settings): revocable tokens, optionally limited to one site, 30 reports per 10 minutes, photos included; kiosk page for shared tablets with large touch targets and auto-reset
|
||||||
|
- [x] Printable QR code sheets per site/area, for signed-in users or through a reporting link, with bilingual captions; scanning pre-fills the location
|
||||||
|
- [x] Anonymous reporting from the app, the kiosk and QR codes
|
||||||
|
- [x] English/Spanish: sign-in pages, navigation, report form, kiosk, My actions, offline messages; language toggle remembered per device; follows the browser language by default
|
||||||
|
- [x] Tests: outbox (IndexedDB), translations complete, reporting links and duplicate-proof retries on the server, browser tests for offline sync, Spanish kiosk via QR, QR pre-fill and installability
|
||||||
|
|
||||||
|
## Phase 4: Compliance (US OSHA)
|
||||||
|
|
||||||
|
- [x] Injured/ill people per incident with a live recordability check (1904.4–1904.11): covered worker, work-related, new case, general criteria, special cases; most-serious-outcome classification; 14-item first-aid list and privacy-case rules shown in context
|
||||||
|
- [x] Day counts with the 180-day cap; days only counted when the matching outcome applies
|
||||||
|
- [x] Severe injury reporting (1904.39): 8-hour/24-hour deadline banner, email alert to safety managers, "reported to OSHA at" record
|
||||||
|
- [x] Form 300 Log (landscape print), Form 300A Summary with establishment details and certification block, printable Form 301 per case
|
||||||
|
- [x] Establishment details per site (address, NAICS, EIN, type) with validation
|
||||||
|
- [x] Monthly hours worked and headcount; TRIR, DART and LTIR
|
||||||
|
- [x] ITA CSV exports for the 300A summary and 300/301 case data (no names), plus OSHA's own upload checks run in the app
|
||||||
|
- [x] Tests: rules module unit tests, API tests incl. exact CSV output, browser test of the full recordkeeping flow
|
||||||
|
|
||||||
|
## Phase 5: Analytics
|
||||||
|
|
||||||
|
- [x] One filter row (date range presets or custom, site, incident type) scoping every number and chart on the page
|
||||||
|
- [x] Leading indicators: hazards & near misses reported, near misses per injury, triaged within 24 hours, actions completed on time; each compared with the previous period
|
||||||
|
- [x] Lagging indicators: injuries, OSHA-recordable cases, rolling 12-month TRIR/DART, average days to close; OSHA rates by calendar year
|
||||||
|
- [x] Charts: reports per week/month by kind, rolling OSHA rates, locations (sites, or areas within a site), root causes, weekday × hour heatmap, actions completed on time vs late
|
||||||
|
- [x] Saved views (private, or shared by supervisors and above) on Analytics and the Incidents list
|
||||||
|
- [x] Demo seed with a year of realistic history (deterministic)
|
||||||
|
- [x] Tests: analytics computations (periods, groupings, rates, scoping), saved views, chart scale helper, browser tests
|
||||||
|
|
||||||
|
## Phase 6: Release v1.0
|
||||||
|
|
||||||
|
- Docker images, backup and restore
|
||||||
|
- Demo instance with seed data
|
||||||
|
- Documentation site and upgrade guide
|
||||||
|
|
||||||
|
## Later
|
||||||
|
|
||||||
|
Inspections and audit checklists, job safety analysis (JSA), training and certification records, management of change, SDS links, public API and webhooks.
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# To-do
|
||||||
|
|
||||||
|
Open items that aren't tied to a single roadmap phase. Tick them off (or delete them) as they're resolved. Phase work lives in [ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
- [ ] **Code of Conduct contact.** [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) uses `jay@equineform.com` for now. Change it to a project alias (e.g. `conduct@<project-domain>`) once the project has its own domain.
|
||||||
|
- [ ] **Source code link.** The About page links to `https://www.equineform.com` for now (default in `src/lib/config.ts`, overridable with `VITE_SOURCE_URL`). Change it to the public git repository once one exists, and use the same URL for issue links in CONTRIBUTING.md.
|
||||||
|
- [ ] **CI on Gitea.** Confirm Actions is enabled for the repository and a runner is registered, then check the first run of `.gitea/workflows/ci.yml` passes. Keep the Playwright container tag in the workflow in sync with `@playwright/test`.
|
||||||
|
|
||||||
|
## Phase 1 follow-ups
|
||||||
|
|
||||||
|
- [ ] **Test against real PostgreSQL.** Server tests use PGlite (PostgreSQL compiled to WebAssembly). Add a CI job with a `postgres` service container that runs the server tests against real PostgreSQL too.
|
||||||
|
- [ ] **Docker build in CI.** Build the image in CI once a runner with Docker is available.
|
||||||
|
- [ ] **Shared login rate limit.** The login limiter is in memory, so it resets on restart and isn't shared between processes. Fine for one container; move it to the database before running several app instances.
|
||||||
|
|
||||||
|
## Phase 2 follow-ups
|
||||||
|
|
||||||
|
- [ ] **Server-side PDF (later).** Decided 2026-10-06 to keep browser-generated PDFs for now: the "Print / PDF" button and print stylesheet are simpler and add no dependencies. Revisit if PDFs need to be produced automatically, e.g. attached to emails or archived on close.
|
||||||
|
- [ ] **Email delivery (later).** Decided 2026-10-06 that the current behavior is fine for now: without `SMTP_URL`, emails print to the server log in development and are dropped (with a startup warning) in production. Later: configure SMTP for real deployments, add a "send test email" button for admins, and consider showing admins a warning in the app when email isn't set up.
|
||||||
|
- [ ] **Notification preferences.** Everyone gets every email that applies to them. Add per-user opt-outs, and an alert-recipients setting per site.
|
||||||
|
- [ ] **Edit corrective actions in the UI.** The API supports changing an action's title, assignee and due date; the incident page only offers status changes and adding new actions.
|
||||||
|
- [ ] **HEIC thumbnails.** iPhone HEIC photos are stored and downloadable but browsers can't show them inline; convert to JPEG on upload.
|
||||||
|
- [ ] **Session check console noise.** Signed-out visitors see one 401 in the browser console from the session check; harmless, but a `200 { user: null }` endpoint would be quieter.
|
||||||
|
|
||||||
|
## Phase 3 follow-ups
|
||||||
|
|
||||||
|
- [ ] **Translate manager screens.** Spanish currently covers what shop-floor workers use (sign-in, report form, kiosk, My actions, navigation). The incident page, investigation, dashboards, analytics, locations and settings are still English. Add their strings to `src/i18n/en.ts` and `es.ts`.
|
||||||
|
- [ ] **Spanish emails.** Notification and sign-in emails are English only. Store a language preference per user and use it for emails.
|
||||||
|
- [ ] **HTTPS for offline use.** Browsers only allow installing the app and the offline app shell over HTTPS (or localhost). Reports still queue offline over plain HTTP once the page is open, but the page itself can't load offline. Document a simple HTTPS setup (e.g. Caddy in front of the app) in the install guide.
|
||||||
|
- [ ] **Have Spanish reviewed by a native speaker** familiar with plant safety terms (e.g. "casi accidente" for near miss, "planta" for site).
|
||||||
|
- [ ] **Offline incident list.** Only reporting works offline; viewing incidents and actions needs a connection.
|
||||||
|
|
||||||
|
## Phase 4 follow-ups
|
||||||
|
|
||||||
|
- [ ] **Expert review of the OSHA rules.** `shared/osha.ts` encodes 29 CFR 1904 as plainly as possible with section references, but it should be reviewed by a recordkeeping professional before anyone relies on it. The app should say clearly that it assists, and the employer remains responsible.
|
||||||
|
- [ ] **Deadline reminders.** Email safety managers in January to certify and post the 300A (Feb 1–Apr 30) and before the ITA submission deadline (March 2).
|
||||||
|
- [ ] **Direct ITA submission.** OSHA offers an API for the Injury Tracking Application; submitting directly would remove the CSV download/upload step.
|
||||||
|
- [ ] **Day counting from dates.** Calculate days away/restricted from start and return-to-work dates instead of typed counts.
|
||||||
|
- [ ] **Employee access (1904.35).** Employees and their representatives may request the 300 Log (and their own 301). Add a way to produce those copies with privacy cases removed.
|
||||||
|
- [ ] **OSHA rates on the dashboard.** Show TRIR/DART on Overview and Analytics, and trends over years.
|
||||||
|
- [ ] **Translate the OSHA pages** along with the other manager screens.
|
||||||
|
- [ ] **Other regions.** The data model keeps outcomes and case types separate from US-specific forms; UK RIDDOR or Canadian provincial rules could be added as further rule sets.
|
||||||
|
|
||||||
|
## Phase 5 follow-ups
|
||||||
|
|
||||||
|
- [ ] **Industry benchmarks.** Show the BLS average TRIR/DART for the site's NAICS code next to its own rates.
|
||||||
|
- [ ] **Scheduled reports.** A weekly or monthly email digest of the indicators for safety managers.
|
||||||
|
- [ ] **Chart export.** Download a chart as an image, or print the whole Analytics page as a report (print styles are in place for most of it).
|
||||||
|
- [ ] **Dark mode.** The app is light-only; if a dark theme is added, the chart colors need their dark-mode steps validated against the dark surface.
|
||||||
|
- [ ] **Translate Analytics** with the other manager screens.
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
- [x] Docker verified 2026-10-06 (Docker 29.8, Compose v5.5): image builds (~315 MB), `docker compose up` with PostgreSQL 18 passes setup → sign-in → report smoke test; data and sessions survive `down`/`up`; clean shutdown; healthcheck healthy; runs as non-root; standalone PGlite container works; `Secure` cookie with an `https://` PUBLIC_URL; web app loads with no errors.
|
||||||
|
|
||||||
|
- [x] Product name: **Open SafetyLog EHS** (decided 2026-10-06). Note: Insight Works sells a similarly named free "Safety Logbook" EHS app for Microsoft Business Central; run a USPTO trademark search before a public launch.
|
||||||
|
- [x] CONTRIBUTING.md and CODE_OF_CONDUCT.md (Contributor Covenant 2.1)
|
||||||
|
- [x] About page with source-code link (AGPL-3.0 section 13)
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: 'postgresql',
|
||||||
|
schema: './server/db/schema.ts',
|
||||||
|
out: './server/db/migrations',
|
||||||
|
casing: 'snake_case',
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { signIn, unique } from './fixtures'
|
||||||
|
|
||||||
|
test('shows leading and lagging indicators with charts that have table views', async ({ page }) => {
|
||||||
|
await signIn(page, 'safety_manager')
|
||||||
|
await page.goto('/analytics?range=365')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Leading indicators' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Hazards & near misses reported')).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Lagging indicators' })).toBeVisible()
|
||||||
|
|
||||||
|
// Every chart has a table view
|
||||||
|
const trend = page.locator('figure').filter({ hasText: 'Reports per' })
|
||||||
|
await trend.getByRole('button', { name: 'Table' }).click()
|
||||||
|
await expect(trend.getByRole('columnheader', { name: 'Hazards & near misses' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filters and saved views', async ({ page, isMobile }) => {
|
||||||
|
test.skip(isMobile, 'Covered on desktop')
|
||||||
|
await signIn(page, 'supervisor')
|
||||||
|
await page.goto('/analytics')
|
||||||
|
await page.getByLabel('Date range').selectOption('30')
|
||||||
|
await page.getByLabel('Incident type').selectOption('hazard')
|
||||||
|
await expect(page).toHaveURL(/range=30/)
|
||||||
|
await expect(page).toHaveURL(/type=hazard/)
|
||||||
|
|
||||||
|
const name = unique('Hazards this month')
|
||||||
|
await page.getByRole('button', { name: 'Save view' }).click()
|
||||||
|
await page.getByLabel('View name').fill(name)
|
||||||
|
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||||
|
await expect(page.getByLabel('Saved views')).toHaveValue(/.+/)
|
||||||
|
|
||||||
|
// Clearing the filters and picking the view brings them back
|
||||||
|
await page.goto('/analytics')
|
||||||
|
await expect(page.getByLabel('Incident type')).toHaveValue('')
|
||||||
|
await page.getByLabel('Saved views').selectOption({ label: name })
|
||||||
|
await expect(page.getByLabel('Incident type')).toHaveValue('hazard')
|
||||||
|
await expect(page.getByLabel('Date range')).toHaveValue('30')
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PASSWORD, signIn, users } from './fixtures'
|
||||||
|
|
||||||
|
test('sends visitors to sign in and back after', async ({ page }) => {
|
||||||
|
await page.goto('/incidents')
|
||||||
|
await expect(page).toHaveURL(/\/login$/)
|
||||||
|
await page.getByLabel('Email').fill(users.reporter.email)
|
||||||
|
await page.getByLabel('Password').fill(PASSWORD)
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||||
|
await expect(page).toHaveURL(/\/incidents$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows an error for a wrong password', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.getByLabel('Email').fill(users.reporter.email)
|
||||||
|
await page.getByLabel('Password').fill('not the password')
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||||
|
await expect(page.getByRole('alert')).toHaveText('Incorrect email or password')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('offers an emailed sign-in link without revealing accounts', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.getByRole('button', { name: 'Email me a sign-in link instead' }).click()
|
||||||
|
await page.getByLabel('Email').fill('nobody@e2e.test')
|
||||||
|
await page.getByRole('button', { name: 'Email me a sign-in link' }).click()
|
||||||
|
await expect(page.getByRole('status')).toContainText('If that email belongs to an account')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('signs out', async ({ page, isMobile }) => {
|
||||||
|
await signIn(page, 'reporter')
|
||||||
|
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
|
||||||
|
await page.getByRole('button', { name: 'Sign out' }).click()
|
||||||
|
await expect(page).toHaveURL(/\/login$/)
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page).toHaveURL(/\/login$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loads with no third-party requests', async ({ page }) => {
|
||||||
|
const external: string[] = []
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (!request.url().startsWith('http://localhost')) external.push(request.url())
|
||||||
|
})
|
||||||
|
await signIn(page, 'reporter')
|
||||||
|
await page.evaluate(() => document.fonts.ready)
|
||||||
|
expect(await page.evaluate(() => document.fonts.check('16px "DM Sans Variable"'))).toBe(true)
|
||||||
|
expect(external).toEqual([])
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
export const PASSWORD = 'e2e test password'
|
||||||
|
export const users = {
|
||||||
|
admin: { name: 'Avery Admin', email: 'admin@e2e.test' },
|
||||||
|
supervisor: { name: 'Sam Supervisor', email: 'supervisor@e2e.test' },
|
||||||
|
reporter: { name: 'Riley Reporter', email: 'reporter@e2e.test' },
|
||||||
|
safety_manager: { name: 'Morgan Manager', email: 'manager@e2e.test' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export async function signIn(page: Page, who: keyof typeof users) {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.getByLabel('Email').fill(users[who].email)
|
||||||
|
await page.getByLabel('Password').fill(PASSWORD)
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toContainText(users[who].name.split(' ')[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A unique title so parallel tests (and the desktop/mobile projects) never collide. */
|
||||||
|
export const unique = (title: string) => `${title} ${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
|
||||||
|
/** 1×1 PNG. */
|
||||||
|
export const photo = {
|
||||||
|
name: 'scene.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'),
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { request } from '@playwright/test'
|
||||||
|
import { baseURL } from '../playwright.config'
|
||||||
|
import { PASSWORD, users } from './fixtures'
|
||||||
|
|
||||||
|
/** Creates the workspace, a site and one user per role in the fresh server. */
|
||||||
|
export default async function globalSetup() {
|
||||||
|
const api = await request.newContext({ baseURL })
|
||||||
|
// oxlint-disable-next-line typescript/no-explicit-any -- loosely typed setup calls
|
||||||
|
const ok = async (res: Awaited<ReturnType<typeof api.post>>): Promise<any> => {
|
||||||
|
if (!res.ok()) throw new Error(`${res.url()} → ${res.status()} ${await res.text()}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
await ok(await api.post('/api/setup', { data: { organizationName: 'E2E Plant', ...users.admin, password: PASSWORD } }))
|
||||||
|
const { site } = await ok(await api.post('/api/sites', { data: { name: 'Main Plant' } }))
|
||||||
|
const { area } = await ok(await api.post(`/api/sites/${site.id}/areas`, { data: { name: 'Press Shop', kind: 'building' } }))
|
||||||
|
const { kiosk } = await ok(await api.post('/api/kiosks', { data: { name: 'Canteen tablet' }, headers: { origin: baseURL } }))
|
||||||
|
// Shared with the test workers
|
||||||
|
process.env.E2E_KIOSK_TOKEN = kiosk.token
|
||||||
|
process.env.E2E_SITE_ID = site.id
|
||||||
|
process.env.E2E_AREA_ID = area.id
|
||||||
|
for (const role of ['supervisor', 'reporter', 'safety_manager'] as const) {
|
||||||
|
await ok(await api.post('/api/users', { data: { ...users[role], role, password: PASSWORD } }))
|
||||||
|
}
|
||||||
|
await api.dispose()
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
import { photo, signIn, unique, users } from './fixtures'
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
/** The incident page section with this heading. */
|
||||||
|
const section = (page: Page, heading: string) => page.locator('section').filter({ has: page.getByRole('heading', { name: heading, exact: true }) })
|
||||||
|
|
||||||
|
async function move(page: Page, button: string) {
|
||||||
|
await page.getByRole('button', { name: button }).click()
|
||||||
|
await expect(page.getByRole('button', { name: button })).toHaveCount(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('takes an incident from report to close across three roles', async ({ browser, isMobile }) => {
|
||||||
|
test.skip(isMobile, 'Long multi-role flow runs on desktop only')
|
||||||
|
test.setTimeout(90_000)
|
||||||
|
const title = unique('Guard missing on press 4')
|
||||||
|
|
||||||
|
// Reporter files the report with a photo
|
||||||
|
const reporter = await (await browser.newContext()).newPage()
|
||||||
|
await signIn(reporter, 'reporter')
|
||||||
|
await reporter.getByRole('link', { name: 'Report incident' }).click()
|
||||||
|
await reporter.getByLabel('Incident title').fill(title)
|
||||||
|
await reporter.getByLabel('Incident type').selectOption('hazard')
|
||||||
|
await reporter.getByLabel('Severity').selectOption('high')
|
||||||
|
await reporter.getByLabel('Building or area').selectOption({ label: 'Press Shop' })
|
||||||
|
await reporter.getByLabel('What happened?').fill('Safety guard removed and left off after maintenance.')
|
||||||
|
await reporter.locator('input[type=file]').setInputFiles(photo)
|
||||||
|
await reporter.getByRole('button', { name: 'Submit report' }).click()
|
||||||
|
await expect(reporter.getByText(/has been reported/)).toBeVisible()
|
||||||
|
await expect(reporter.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||||
|
await expect(reporter.getByRole('img', { name: 'scene.png' })).toBeVisible()
|
||||||
|
const url = reporter.url()
|
||||||
|
|
||||||
|
// Supervisor triages, investigates and assigns an action back to the reporter
|
||||||
|
const supervisor = await (await browser.newContext()).newPage()
|
||||||
|
await signIn(supervisor, 'supervisor')
|
||||||
|
await supervisor.goto(url)
|
||||||
|
await move(supervisor, 'Start triage')
|
||||||
|
await move(supervisor, 'Start investigation')
|
||||||
|
await supervisor.getByRole('button', { name: 'Add root cause' }).click()
|
||||||
|
await supervisor.getByLabel('Category').selectOption('process')
|
||||||
|
await supervisor.getByLabel('Root cause').fill('No step to refit guards after maintenance')
|
||||||
|
await supervisor.getByLabel('Why 1').fill('Guard was not refitted')
|
||||||
|
await supervisor.getByRole('button', { name: 'Add root cause' }).click()
|
||||||
|
await expect(section(supervisor, 'Investigation').getByText('No step to refit guards after maintenance')).toBeVisible()
|
||||||
|
await move(supervisor, 'Move to corrective actions')
|
||||||
|
await supervisor.getByRole('button', { name: 'Add action' }).click()
|
||||||
|
await supervisor.getByLabel('Action', { exact: true }).fill('Add guard check to maintenance sign-off')
|
||||||
|
await supervisor.getByLabel('Assign to').selectOption({ label: users.reporter.name })
|
||||||
|
await supervisor.getByRole('button', { name: 'Add action' }).click()
|
||||||
|
await expect(section(supervisor, 'Corrective actions').getByText(`Assigned to ${users.reporter.name}`)).toBeVisible()
|
||||||
|
await expect(supervisor.getByRole('button', { name: 'Send for verification' })).toBeDisabled()
|
||||||
|
|
||||||
|
// Reporter completes the action from My actions
|
||||||
|
await reporter.goto('/actions')
|
||||||
|
const row = reporter.getByRole('listitem').filter({ hasText: 'Add guard check to maintenance sign-off' })
|
||||||
|
await row.getByRole('combobox').selectOption('done')
|
||||||
|
await expect(row).toHaveCount(0) // leaves the Open list
|
||||||
|
await reporter.getByRole('tab', { name: 'Done' }).click()
|
||||||
|
await expect(row.getByRole('combobox')).toHaveValue('done')
|
||||||
|
|
||||||
|
// Supervisor sends it for verification; admin verifies and closes
|
||||||
|
await supervisor.reload()
|
||||||
|
await move(supervisor, 'Send for verification')
|
||||||
|
const admin = await (await browser.newContext()).newPage()
|
||||||
|
await signIn(admin, 'admin')
|
||||||
|
await admin.goto(url)
|
||||||
|
await expect(admin.getByRole('button', { name: 'Verify and close' })).toBeDisabled()
|
||||||
|
await admin.getByRole('combobox', { name: /Status of Add guard check/ }).selectOption('verified')
|
||||||
|
await admin.getByLabel('Note').fill('Checked on the floor: guard refitted and checklist updated.')
|
||||||
|
await move(admin, 'Verify and close')
|
||||||
|
|
||||||
|
// The record shows the whole story
|
||||||
|
await expect(admin.getByText('Closed', { exact: true }).first()).toBeVisible()
|
||||||
|
const activity = section(admin, 'Activity')
|
||||||
|
await expect(activity.getByText('moved it from Verification to Closed')).toBeVisible()
|
||||||
|
await expect(activity.getByText('Checked on the floor')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { signIn, unique } from './fixtures'
|
||||||
|
|
||||||
|
test('reports from a phone and finds the incident again', async ({ page, isMobile }) => {
|
||||||
|
const title = unique(isMobile ? 'Phone report: oil on floor' : 'Desktop report: oil on floor')
|
||||||
|
await signIn(page, 'reporter')
|
||||||
|
if (isMobile) {
|
||||||
|
await page.getByRole('button', { name: 'Open menu' }).click()
|
||||||
|
await page.getByRole('link', { name: 'Report an incident' }).click()
|
||||||
|
} else {
|
||||||
|
await page.getByRole('link', { name: 'Report incident' }).click()
|
||||||
|
}
|
||||||
|
await page.getByLabel('Incident title').fill(title)
|
||||||
|
await page.getByRole('button', { name: 'Submit report' }).click()
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||||
|
const reference = (await page.locator('text=/INC-\\d{4}/').first().textContent())!.match(/INC-\d{4}/)![0]
|
||||||
|
|
||||||
|
await page.goto('/incidents')
|
||||||
|
await page.getByLabel('Search incidents').last().fill(reference)
|
||||||
|
await expect(page.getByRole('link', { name: title })).toBeVisible()
|
||||||
|
await expect(page.getByRole('row')).toHaveCount(2) // header + match
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filters the register', async ({ page }) => {
|
||||||
|
await signIn(page, 'supervisor')
|
||||||
|
await page.goto('/incidents?status=closed&type=environmental')
|
||||||
|
await expect(page.getByText('No incidents match these filters.')).toBeVisible()
|
||||||
|
await expect(page.getByLabel('Filter by status')).toHaveValue('closed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reporters can't see other people's reports", async ({ page, browser }) => {
|
||||||
|
const title = unique('Supervisor-only report')
|
||||||
|
const supervisor = await (await browser.newContext()).newPage()
|
||||||
|
await signIn(supervisor, 'supervisor')
|
||||||
|
await supervisor.goto('/report')
|
||||||
|
await supervisor.getByLabel('Incident title').fill(title)
|
||||||
|
await supervisor.getByRole('button', { name: 'Submit report' }).click()
|
||||||
|
await expect(supervisor.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||||
|
const url = new URL(supervisor.url()).pathname
|
||||||
|
|
||||||
|
await signIn(page, 'reporter')
|
||||||
|
await page.goto(url)
|
||||||
|
await expect(page.getByText('doesn’t exist or you don’t have access')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { signIn, unique } from './fixtures'
|
||||||
|
|
||||||
|
test('records an injury on the OSHA log and exports it', async ({ page, browser, isMobile }) => {
|
||||||
|
test.skip(isMobile, 'Recordkeeping is a desktop task')
|
||||||
|
test.setTimeout(60_000)
|
||||||
|
const title = unique('Burn from hot press platen')
|
||||||
|
|
||||||
|
// A supervisor reports it; they can't see medical details
|
||||||
|
const supervisor = await (await browser.newContext()).newPage()
|
||||||
|
await signIn(supervisor, 'supervisor')
|
||||||
|
await supervisor.goto('/report')
|
||||||
|
await supervisor.getByLabel('Incident title').fill(title)
|
||||||
|
await supervisor.getByLabel('Incident type').selectOption('injury')
|
||||||
|
await supervisor.getByRole('button', { name: 'Submit report' }).click()
|
||||||
|
await expect(supervisor.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||||
|
const url = supervisor.url()
|
||||||
|
|
||||||
|
// The safety manager decides recordability
|
||||||
|
await signIn(page, 'safety_manager')
|
||||||
|
await page.goto(url)
|
||||||
|
await page.getByRole('button', { name: 'Add person' }).click()
|
||||||
|
await page.getByLabel('Name', { exact: true }).fill('Casey Worker')
|
||||||
|
await page.getByPlaceholder('e.g. Press operator').fill('Press operator')
|
||||||
|
const verdict = page.locator('[aria-live="polite"]')
|
||||||
|
await expect(verdict).toContainText('Answer the questions above')
|
||||||
|
await page.getByText('Is it work-related?').locator('..').getByLabel('Yes').check()
|
||||||
|
await page.getByText('Is it a new case?').locator('..').getByLabel('Yes').check()
|
||||||
|
await expect(verdict).toContainText('Not recordable') // first aid only
|
||||||
|
await page.getByText('Days away from work (count').click()
|
||||||
|
await page.getByLabel('Days away so far').fill('3')
|
||||||
|
await expect(verdict).toContainText('Recordable: Days away from work')
|
||||||
|
await page.getByText('Hospitalized as an in-patient').click()
|
||||||
|
await expect(page.getByText('Must be reported to OSHA within 24 hours.')).toBeVisible()
|
||||||
|
await page.getByPlaceholder('e.g. Laceration, sprain, burn').fill('Second-degree burn')
|
||||||
|
await page.getByRole('button', { name: 'Add person' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('OSHA recordable · Days away from work')).toBeVisible()
|
||||||
|
await expect(page.getByRole('alert').filter({ hasText: 'Report to OSHA within 24 hours' })).toBeVisible()
|
||||||
|
|
||||||
|
// Supervisors see that someone was hurt, nothing more
|
||||||
|
await supervisor.reload()
|
||||||
|
await expect(supervisor.getByText('1 person recorded. Injury and medical details are confidential')).toBeVisible()
|
||||||
|
await expect(supervisor.getByText('Casey Worker')).toHaveCount(0)
|
||||||
|
|
||||||
|
// It's on the 300 Log, and the 301 is one click away
|
||||||
|
await page.getByRole('link', { name: 'OSHA log' }).click()
|
||||||
|
const row = page.getByRole('row').filter({ hasText: 'Casey Worker' })
|
||||||
|
await expect(row).toContainText('Second-degree burn')
|
||||||
|
await expect(row).toContainText('Missing:')
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Submit to OSHA' }).click()
|
||||||
|
await expect(page.getByText('Enter hours worked for each month.')).toBeVisible()
|
||||||
|
const download = page.waitForEvent('download')
|
||||||
|
await page.getByRole('link', { name: /300A summary CSV/ }).click()
|
||||||
|
expect((await download).suggestedFilename()).toMatch(/^osha-ita-300a-\d{4}\.csv$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("OSHA pages aren't offered to supervisors", async ({ page, isMobile }) => {
|
||||||
|
await signIn(page, 'supervisor')
|
||||||
|
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
|
||||||
|
await expect(page.getByRole('link', { name: 'Incidents' }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: 'OSHA log' })).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { signIn, unique, users } from './fixtures'
|
||||||
|
|
||||||
|
const token = () => process.env.E2E_KIOSK_TOKEN!
|
||||||
|
const site = () => process.env.E2E_SITE_ID!
|
||||||
|
const area = () => process.env.E2E_AREA_ID!
|
||||||
|
|
||||||
|
test('saves a report offline and sends it when the connection returns', async ({ page, context }) => {
|
||||||
|
const title = unique('Offline report: frayed sling')
|
||||||
|
await signIn(page, 'reporter')
|
||||||
|
await page.goto('/report')
|
||||||
|
await expect(page.getByLabel('Incident title')).toBeVisible()
|
||||||
|
|
||||||
|
await context.setOffline(true)
|
||||||
|
await page.getByLabel('Incident title').fill(title)
|
||||||
|
await page.getByRole('button', { name: 'Submit report' }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Saved on this device' })).toBeVisible()
|
||||||
|
await expect(page.getByText('1 report waiting to send')).toBeVisible()
|
||||||
|
|
||||||
|
await context.setOffline(false)
|
||||||
|
await expect(page.getByText('1 report waiting to send')).toBeHidden({ timeout: 15_000 })
|
||||||
|
await page.goto('/incidents')
|
||||||
|
await expect(page.getByRole('link', { name: title })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kiosk: reports without an account, in Spanish, from a QR code', async ({ browser, page }) => {
|
||||||
|
const title = unique('Cable suelto en el pasillo')
|
||||||
|
const kiosk = await (await browser.newContext({ locale: 'es-MX' })).newPage()
|
||||||
|
await kiosk.goto(`/k/${token()}?site=${site()}&area=${area()}`)
|
||||||
|
await expect(kiosk.getByRole('heading', { name: 'Reporte un problema de seguridad' })).toBeVisible()
|
||||||
|
await expect(kiosk.getByLabel('Edificio o área')).toHaveValue(area())
|
||||||
|
|
||||||
|
await kiosk.getByText('Peligro', { exact: true }).click()
|
||||||
|
await kiosk.getByText('Alta', { exact: true }).click()
|
||||||
|
await kiosk.getByLabel('En pocas palabras, ¿qué vio?').fill(title)
|
||||||
|
await kiosk.getByLabel('Su nombre').fill('Luis')
|
||||||
|
await kiosk.getByRole('button', { name: 'Enviar reporte' }).click()
|
||||||
|
await expect(kiosk.getByRole('heading', { name: 'Gracias' })).toBeVisible()
|
||||||
|
const reference = (await kiosk.getByText(/Su reporte INC-\d{4}/).textContent())!.match(/INC-\d{4}/)![0]
|
||||||
|
|
||||||
|
// Switching language works on the kiosk too
|
||||||
|
await kiosk.getByRole('button', { name: 'Reportar otra cosa' }).click()
|
||||||
|
await kiosk.getByRole('button', { name: 'English' }).click()
|
||||||
|
await expect(kiosk.getByRole('heading', { name: 'Report a safety concern' })).toBeVisible()
|
||||||
|
|
||||||
|
// The safety team sees who reported it and through which link
|
||||||
|
await signIn(page, 'admin')
|
||||||
|
await page.goto(`/incidents/${Number(reference.slice(4))}`)
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||||
|
await expect(page.getByText('Reported by Luis via Canteen tablet')).toBeVisible()
|
||||||
|
await expect(page.getByText('Press Shop').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kiosk: a made-up or revoked link explains itself', async ({ page }) => {
|
||||||
|
await page.goto('/k/not-a-real-token')
|
||||||
|
await expect(page.getByText('This reporting link isn’t working')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('QR codes open the report form with the location filled in', async ({ page, isMobile }) => {
|
||||||
|
test.skip(isMobile, 'Printing QR sheets is a desktop task')
|
||||||
|
await signIn(page, 'admin')
|
||||||
|
await page.goto(`/locations/qr?site=${site()}`)
|
||||||
|
await expect(page.getByRole('img', { name: /QR code for/ })).toHaveCount(2) // whole site + Press Shop
|
||||||
|
const url = await page.locator('main').getByText(/\/report\?site=/).nth(1).textContent()
|
||||||
|
expect(url).toContain(`area=${area()}`)
|
||||||
|
|
||||||
|
await page.goto(url!.trim())
|
||||||
|
await expect(page.getByText('Location filled in from the QR code.')).toBeVisible()
|
||||||
|
await expect(page.getByLabel('Building or area')).toHaveValue(area())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the signed-in app speaks Spanish too', async ({ page, isMobile }) => {
|
||||||
|
await signIn(page, 'reporter')
|
||||||
|
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
|
||||||
|
await page.getByRole('button', { name: 'Español' }).click()
|
||||||
|
if (isMobile) await page.getByRole('button', { name: 'Cerrar menú' }).click()
|
||||||
|
await page.goto('/report')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Reportar un incidente' })).toBeVisible()
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('lang', 'es')
|
||||||
|
await page.goto('/actions')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Mis acciones' })).toBeVisible()
|
||||||
|
expect(users.reporter.email).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('is installable: manifest and service worker', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
const manifest = await (await page.request.get('/manifest.webmanifest')).json()
|
||||||
|
expect(manifest).toMatchObject({ name: 'Open SafetyLog EHS', display: 'standalone' })
|
||||||
|
expect(manifest.icons.map((icon: { sizes: string }) => icon.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512']))
|
||||||
|
const scope = await page.evaluate(async () => (await navigator.serviceWorker.ready).scope)
|
||||||
|
expect(scope).toMatch(/\/$/)
|
||||||
|
})
|
||||||
@@ -3,11 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
|
<meta name="theme-color" content="#163c37" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>iatracker</title>
|
<title>Open SafetyLog EHS</title>
|
||||||
|
<meta name="description" content="Open-source accident and safety tracking for manufacturing" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,27 +1,71 @@
|
|||||||
{
|
{
|
||||||
"name": "iatracker",
|
"name": "open-safetylog-ehs",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Open-source accident and safety tracking for manufacturing",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.18"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "concurrently -k -n web,api -c cyan,magenta \"npm:dev:web\" \"npm:dev:server\"",
|
||||||
|
"dev:web": "vite",
|
||||||
|
"dev:server": "node --watch --env-file-if-exists=.env server/index.ts",
|
||||||
|
"start": "node server/index.ts",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "oxlint",
|
"preview": "vite preview",
|
||||||
"preview": "vite preview"
|
"lint": "oxlint --deny-warnings",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:seed": "node --env-file-if-exists=.env server/db/seed.ts",
|
||||||
|
"check": "npm run lint && npm run typecheck && npm test && npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@electric-sql/pglite": "^0.5.8",
|
||||||
"lucide-react": "^1.39.0",
|
"@hono/node-server": "^2.1.3",
|
||||||
"react": "^19.2.8",
|
"@hono/zod-validator": "^0.9.1",
|
||||||
"react-dom": "^19.2.8",
|
"drizzle-orm": "^0.45.3",
|
||||||
"react-router-dom": "^7.18.3",
|
"hono": "^4.13.13",
|
||||||
"tailwindcss": "^4.3.3"
|
"nodemailer": "^10.0.15",
|
||||||
|
"pg": "^8.23.1",
|
||||||
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@fontsource-variable/dm-sans": "^5.3.0",
|
||||||
|
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||||
|
"@fontsource/dm-mono": "^5.3.0",
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tanstack/react-query": "^5.104.1",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
|
"@testing-library/user-event": "^14.6.7",
|
||||||
|
"@types/node": "^26.6.4",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@vitejs/plugin-react": "^6.1.0",
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
"concurrently": "^10.0.5",
|
||||||
|
"drizzle-kit": "^0.31.11",
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
|
"jsdom": "^30.1.2",
|
||||||
|
"lucide-react": "^1.39.0",
|
||||||
"oxlint": "^1.79.0",
|
"oxlint": "^1.79.0",
|
||||||
"vite": "^8.2.2"
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.3",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.2.2",
|
||||||
|
"vite-plugin-pwa": "^2.0.0",
|
||||||
|
"vitest": "^5.0.3",
|
||||||
|
"workbox-window": "^7.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
const port = 4173
|
||||||
|
export const baseURL = `http://localhost:${port}`
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
globalSetup: './e2e/global-setup.ts',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
|
||||||
|
],
|
||||||
|
// The real server with a throwaway in-memory database, serving the production build
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run build && node server/index.ts',
|
||||||
|
port,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
env: { DATABASE_URL: 'memory://', PORT: String(port), PUBLIC_URL: baseURL, UPLOAD_DIR: 'test-results/uploads', STATIC_DIR: 'dist' },
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#ee7258"/><path d="M16 6.5 8.5 9.5v6c0 4.6 3.2 8.6 7.5 9.9 4.3-1.3 7.5-5.3 7.5-9.9v-6L16 6.5Z" fill="none" stroke="#fff" stroke-width="2.2" stroke-linejoin="round"/><path d="m12.5 16 2.4 2.4 4.6-4.8" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 402 B |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
// Renders the app icon to the PNG sizes browsers need to install the app.
|
||||||
|
// Run after changing the icon: node scripts/generate-icons.mjs
|
||||||
|
import { chromium } from '@playwright/test'
|
||||||
|
|
||||||
|
const shield = (stroke) => `
|
||||||
|
<path d="M16 6.5 8.5 9.5v6c0 4.6 3.2 8.6 7.5 9.9 4.3-1.3 7.5-5.3 7.5-9.9v-6L16 6.5Z" fill="none" stroke="#fff" stroke-width="${stroke}" stroke-linejoin="round"/>
|
||||||
|
<path d="m12.5 16 2.4 2.4 4.6-4.8" fill="none" stroke="#fff" stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||||
|
|
||||||
|
// Regular icon: rounded tile. Maskable: full-bleed square with the mark inside the safe zone.
|
||||||
|
const regular = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#ee7258"/>${shield(2.2)}</svg>`
|
||||||
|
const maskable = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#ee7258"/><g transform="translate(16 16) scale(0.72) translate(-16 -16)">${shield(2.4)}</g></svg>`
|
||||||
|
|
||||||
|
const outputs = [
|
||||||
|
['public/icons/icon-192.png', regular, 192],
|
||||||
|
['public/icons/icon-512.png', regular, 512],
|
||||||
|
['public/icons/maskable-512.png', maskable, 512],
|
||||||
|
['public/icons/apple-touch-icon.png', maskable, 180],
|
||||||
|
]
|
||||||
|
|
||||||
|
const browser = await chromium.launch()
|
||||||
|
const page = await browser.newPage()
|
||||||
|
for (const [path, svg, size] of outputs) {
|
||||||
|
await page.setViewportSize({ width: size, height: size })
|
||||||
|
await page.setContent(`<style>html,body{margin:0;background:transparent}</style><img src="data:image/svg+xml,${encodeURIComponent(svg)}" width="${size}" height="${size}">`)
|
||||||
|
await page.locator('img').screenshot({ path, omitBackground: true })
|
||||||
|
console.log('wrote', path)
|
||||||
|
}
|
||||||
|
await browser.close()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Hono } from 'hono'
|
||||||
|
import { csrf } from 'hono/csrf'
|
||||||
|
import { HTTPException } from 'hono/http-exception'
|
||||||
|
import { secureHeaders } from 'hono/secure-headers'
|
||||||
|
import { loadSession } from './auth/middleware.ts'
|
||||||
|
import type { Config } from './config.ts'
|
||||||
|
import type { Db } from './db/client.ts'
|
||||||
|
import type { AppEnv } from './lib/http.ts'
|
||||||
|
import type { Notifier } from './notifications.ts'
|
||||||
|
import { actionRoutes } from './routes/actions.ts'
|
||||||
|
import { attachmentRoutes } from './routes/attachments.ts'
|
||||||
|
import { authRoutes } from './routes/auth.ts'
|
||||||
|
import { incidentRoutes } from './routes/incidents.ts'
|
||||||
|
import { kioskAdminRoutes, kioskPublicRoutes } from './routes/kiosks.ts'
|
||||||
|
import { oshaRoutes } from './routes/osha.ts'
|
||||||
|
import { rootCauseRoutes } from './routes/root-causes.ts'
|
||||||
|
import { setupRoutes } from './routes/setup.ts'
|
||||||
|
import { areaRoutes, siteRoutes } from './routes/sites.ts'
|
||||||
|
import { statsRoutes } from './routes/stats.ts'
|
||||||
|
import { userRoutes } from './routes/users.ts'
|
||||||
|
import { viewRoutes } from './routes/views.ts'
|
||||||
|
import type { FileStorage } from './storage.ts'
|
||||||
|
|
||||||
|
export interface AppDeps {
|
||||||
|
db: Db
|
||||||
|
config: Config
|
||||||
|
notifier: Notifier
|
||||||
|
storage: FileStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp({ db, config, notifier, storage }: AppDeps) {
|
||||||
|
const app = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
app.use('*', secureHeaders())
|
||||||
|
const publicOrigin = new URL(config.publicUrl).origin
|
||||||
|
// Form/multipart posts must come from our own pages. JSON requests are already protected by CORS preflight.
|
||||||
|
const sameSite = csrf({ origin: (origin, c) => origin === publicOrigin || origin === new URL(c.req.url).origin })
|
||||||
|
|
||||||
|
app.use('/api/*', sameSite, async (c, next) => {
|
||||||
|
c.set('db', db)
|
||||||
|
c.set('config', config)
|
||||||
|
c.set('notifier', notifier)
|
||||||
|
c.set('storage', storage)
|
||||||
|
c.header('Cache-Control', 'no-store')
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
app.use('/api/*', loadSession)
|
||||||
|
|
||||||
|
app.get('/api/health', (c) => c.json({ ok: true }))
|
||||||
|
app.route('/api/setup', setupRoutes)
|
||||||
|
app.route('/api/auth', authRoutes())
|
||||||
|
app.route('/api/users', userRoutes)
|
||||||
|
app.route('/api/sites', siteRoutes)
|
||||||
|
app.route('/api/areas', areaRoutes)
|
||||||
|
app.route('/api/incidents', incidentRoutes)
|
||||||
|
app.route('/api/root-causes', rootCauseRoutes)
|
||||||
|
app.route('/api/actions', actionRoutes)
|
||||||
|
app.route('/api/attachments', attachmentRoutes)
|
||||||
|
app.route('/api/stats', statsRoutes)
|
||||||
|
app.route('/api/kiosks', kioskAdminRoutes)
|
||||||
|
app.route('/api/kiosk', kioskPublicRoutes())
|
||||||
|
app.route('/api/osha', oshaRoutes)
|
||||||
|
app.route('/api/views', viewRoutes)
|
||||||
|
app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404))
|
||||||
|
|
||||||
|
app.onError((error, c) => {
|
||||||
|
if (error instanceof HTTPException) return c.json({ error: error.message }, error.status)
|
||||||
|
console.error(error)
|
||||||
|
return c.json({ error: 'Internal server error' }, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
export type App = ReturnType<typeof createApp>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Context, MiddlewareHandler } from 'hono'
|
||||||
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||||
|
import { hasRole, type Role } from '../../shared/enums.ts'
|
||||||
|
import { currentUser, fail, type AppEnv } from '../lib/http.ts'
|
||||||
|
import { createSession, deleteSession, SESSION_COOKIE, validateSession } from './sessions.ts'
|
||||||
|
|
||||||
|
function writeCookie(c: Context<AppEnv>, token: string, expires: Date) {
|
||||||
|
setCookie(c, SESSION_COOKIE, token, { httpOnly: true, sameSite: 'Lax', secure: c.get('config').secureCookies, path: '/', expires })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads the user from the session cookie, if any. */
|
||||||
|
export const loadSession: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||||
|
const token = getCookie(c, SESSION_COOKIE)
|
||||||
|
const session = token ? await validateSession(c.get('db'), token) : null
|
||||||
|
if (token && !session) deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
||||||
|
if (token && session?.renewedUntil) writeCookie(c, token, session.renewedUntil)
|
||||||
|
c.set('user', session?.user ?? null)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||||
|
currentUser(c)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRole(minimum: Role): MiddlewareHandler<AppEnv> {
|
||||||
|
return async (c, next) => {
|
||||||
|
if (!hasRole(currentUser(c).role, minimum)) throw fail(403, 'You do not have permission to do that')
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startSession(c: Context<AppEnv>, userId: string): Promise<void> {
|
||||||
|
const { token, expiresAt } = await createSession(c.get('db'), userId)
|
||||||
|
writeCookie(c, token, expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function endSession(c: Context<AppEnv>): Promise<void> {
|
||||||
|
const token = getCookie(c, SESSION_COOKIE)
|
||||||
|
if (token) await deleteSession(c.get('db'), token)
|
||||||
|
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { hashPassword, verifyPassword } from './password.ts'
|
||||||
|
|
||||||
|
describe('password hashing', () => {
|
||||||
|
it('verifies the right password and rejects the wrong one', async () => {
|
||||||
|
const hash = await hashPassword('correct horse battery')
|
||||||
|
expect(hash).toMatch(/^scrypt\$32768\$8\$3\$/)
|
||||||
|
expect(await verifyPassword('correct horse battery', hash)).toBe(true)
|
||||||
|
expect(await verifyPassword('wrong horse battery', hash)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('salts each hash', async () => {
|
||||||
|
expect(await hashPassword('same password')).not.toBe(await hashPassword('same password'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed hashes', async () => {
|
||||||
|
expect(await verifyPassword('anything', 'not-a-hash')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { randomBytes, scrypt, timingSafeEqual, type ScryptOptions } from 'node:crypto'
|
||||||
|
|
||||||
|
// scrypt parameters from the OWASP Password Storage Cheat Sheet (N=2^15, r=8, p=3: ~32 MiB per hash).
|
||||||
|
const params = { N: 2 ** 15, r: 8, p: 3 }
|
||||||
|
const keyLength = 64
|
||||||
|
|
||||||
|
function derive(password: string, salt: Buffer, options: ScryptOptions): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
scrypt(password.normalize('NFKC'), salt, keyLength, { ...options, maxmem: 64 * 1024 * 1024 }, (error, key) => (error ? reject(error) : resolve(key))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns `scrypt$N$r$p$salt$hash` so parameters can be raised later without breaking old hashes. */
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
const salt = randomBytes(16)
|
||||||
|
const key = await derive(password, salt, params)
|
||||||
|
return ['scrypt', params.N, params.r, params.p, salt.toString('base64'), key.toString('base64')].join('$')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||||
|
const [algorithm, N, r, p, salt, hash] = stored.split('$')
|
||||||
|
if (algorithm !== 'scrypt' || !salt || !hash) return false
|
||||||
|
const expected = Buffer.from(hash, 'base64')
|
||||||
|
const actual = await derive(password, Buffer.from(salt, 'base64'), { N: Number(N), r: Number(r), p: Number(p) })
|
||||||
|
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Burns comparable time when the user doesn't exist, so login timing doesn't reveal valid emails. */
|
||||||
|
let dummyHash: Promise<string> | undefined
|
||||||
|
export async function verifyAgainstDummy(password: string): Promise<false> {
|
||||||
|
dummyHash ??= hashPassword('dummy-password-for-timing')
|
||||||
|
await verifyPassword(password, await dummyHash)
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** Fixed-window in-memory limiter. Fine for a single server process; move to the database if we scale out. */
|
||||||
|
export class RateLimiter {
|
||||||
|
private hits = new Map<string, { count: number; resetAt: number }>()
|
||||||
|
private readonly limit: number
|
||||||
|
private readonly windowMs: number
|
||||||
|
|
||||||
|
constructor(limit: number, windowMs: number) {
|
||||||
|
this.limit = limit
|
||||||
|
this.windowMs = windowMs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records an attempt; returns false once the key has exceeded the limit. */
|
||||||
|
attempt(key: string, now = Date.now()): boolean {
|
||||||
|
const entry = this.hits.get(key)
|
||||||
|
if (!entry || entry.resetAt <= now) {
|
||||||
|
this.hits.set(key, { count: 1, resetAt: now + this.windowMs })
|
||||||
|
if (this.hits.size > 10_000) this.prune(now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
entry.count += 1
|
||||||
|
return entry.count <= this.limit
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(key: string): void {
|
||||||
|
this.hits.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private prune(now: number) {
|
||||||
|
for (const [key, entry] of this.hits) if (entry.resetAt <= now) this.hits.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { and, eq, gt } from 'drizzle-orm'
|
||||||
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
|
import type { Role } from '../../shared/enums.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { sessions, users } from '../db/schema.ts'
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = 'safetylog_session'
|
||||||
|
export const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export interface SessionUser {
|
||||||
|
id: string
|
||||||
|
organizationId: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
role: Role
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashToken = (token: string) => createHash('sha256').update(token).digest('hex')
|
||||||
|
|
||||||
|
export async function createSession(db: Db, userId: string): Promise<{ token: string; expiresAt: Date }> {
|
||||||
|
const token = randomBytes(32).toString('base64url')
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS)
|
||||||
|
await db.insert(sessions).values({ id: hashToken(token), userId, expiresAt })
|
||||||
|
return { token, expiresAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the session's user, extending the session once it is past half its lifetime. */
|
||||||
|
export async function validateSession(db: Db, token: string): Promise<{ user: SessionUser; renewedUntil?: Date } | null> {
|
||||||
|
const id = hashToken(token)
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
expiresAt: sessions.expiresAt,
|
||||||
|
user: { id: users.id, organizationId: users.organizationId, email: users.email, name: users.name, role: users.role },
|
||||||
|
})
|
||||||
|
.from(sessions)
|
||||||
|
.innerJoin(users, eq(users.id, sessions.userId))
|
||||||
|
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.active, true)))
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
if (row.expiresAt.getTime() - Date.now() < SESSION_TTL_MS / 2) {
|
||||||
|
const renewedUntil = new Date(Date.now() + SESSION_TTL_MS)
|
||||||
|
await db.update(sessions).set({ expiresAt: renewedUntil }).where(eq(sessions.id, id))
|
||||||
|
return { user: row.user, renewedUntil }
|
||||||
|
}
|
||||||
|
return { user: row.user }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession(db: Db, token: string): Promise<void> {
|
||||||
|
await db.delete(sessions).where(eq(sessions.id, hashToken(token)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserSessions(db: Db, userId: string): Promise<void> {
|
||||||
|
await db.delete(sessions).where(eq(sessions.userId, userId))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { and, eq, gt, isNull } from 'drizzle-orm'
|
||||||
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { authTokens, users } from '../db/schema.ts'
|
||||||
|
|
||||||
|
export type TokenPurpose = 'magic_link' | 'password_reset'
|
||||||
|
|
||||||
|
const hash = (token: string) => createHash('sha256').update(token).digest('hex')
|
||||||
|
|
||||||
|
export async function createAuthToken(db: Db, userId: string, purpose: TokenPurpose, ttlMs: number): Promise<string> {
|
||||||
|
const token = randomBytes(32).toString('base64url')
|
||||||
|
await db.insert(authTokens).values({ id: hash(token), userId, purpose, expiresAt: new Date(Date.now() + ttlMs) })
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the token used and returns its (active) user's id, or null if invalid, expired or already used. */
|
||||||
|
export async function consumeAuthToken(db: Db, token: string, purpose: TokenPurpose): Promise<string | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.update(authTokens)
|
||||||
|
.set({ usedAt: new Date() })
|
||||||
|
.where(and(eq(authTokens.id, hash(token)), eq(authTokens.purpose, purpose), isNull(authTokens.usedAt), gt(authTokens.expiresAt, new Date())))
|
||||||
|
.returning({ userId: authTokens.userId })
|
||||||
|
if (!row) return null
|
||||||
|
const [user] = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(and(eq(users.id, row.userId), eq(users.active, true)))
|
||||||
|
return user?.id ?? null
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export interface Config {
|
||||||
|
databaseUrl: string
|
||||||
|
port: number
|
||||||
|
/** Public URL users open in their browser; HTTPS here turns on secure cookies. */
|
||||||
|
publicUrl: string
|
||||||
|
secureCookies: boolean
|
||||||
|
production: boolean
|
||||||
|
/** Built web app to serve, if present. */
|
||||||
|
staticDir: string
|
||||||
|
/** e.g. `smtps://user:pass@smtp.example.com:465`. Without it, emails are printed to the log (development only). */
|
||||||
|
smtpUrl: string | undefined
|
||||||
|
mailFrom: string
|
||||||
|
uploadDir: string
|
||||||
|
maxUploadBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||||
|
const publicUrl = env.PUBLIC_URL ?? `http://localhost:${env.PORT ?? 3000}`
|
||||||
|
return {
|
||||||
|
databaseUrl: env.DATABASE_URL ?? 'pglite://./data/pglite',
|
||||||
|
port: Number(env.PORT ?? 3000),
|
||||||
|
publicUrl,
|
||||||
|
secureCookies: publicUrl.startsWith('https://'),
|
||||||
|
production: env.NODE_ENV === 'production',
|
||||||
|
staticDir: env.STATIC_DIR ?? 'dist',
|
||||||
|
smtpUrl: env.SMTP_URL || undefined,
|
||||||
|
mailFrom: env.MAIL_FROM ?? 'Open SafetyLog EHS <no-reply@localhost>',
|
||||||
|
uploadDir: env.UPLOAD_DIR ?? './data/uploads',
|
||||||
|
maxUploadBytes: Number(env.MAX_UPLOAD_MB ?? 10) * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { PGlite } from '@electric-sql/pglite'
|
||||||
|
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'
|
||||||
|
import { migrate as migratePg } from 'drizzle-orm/node-postgres/migrator'
|
||||||
|
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'
|
||||||
|
import { drizzle as drizzlePglite } from 'drizzle-orm/pglite'
|
||||||
|
import { migrate as migratePglite } from 'drizzle-orm/pglite/migrator'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import pg from 'pg'
|
||||||
|
import * as schema from './schema.ts'
|
||||||
|
|
||||||
|
export type Db = PgDatabase<PgQueryResultHKT, typeof schema>
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
db: Db
|
||||||
|
close(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationsFolder = join(import.meta.dirname, 'migrations')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the database and applies pending migrations.
|
||||||
|
*
|
||||||
|
* - `postgres://…` connects to PostgreSQL (production).
|
||||||
|
* - `pglite://<dir>` uses embedded PostgreSQL stored in `<dir>` (local development, very small installs).
|
||||||
|
* - `memory://` uses a throwaway in-memory database (tests).
|
||||||
|
*/
|
||||||
|
export async function openDatabase(url: string): Promise<Database> {
|
||||||
|
if (/^postgres(ql)?:\/\//.test(url)) {
|
||||||
|
const pool = new pg.Pool({ connectionString: url })
|
||||||
|
const db = drizzlePg({ client: pool, schema, casing: 'snake_case' })
|
||||||
|
await migratePg(db, { migrationsFolder })
|
||||||
|
return { db: db as unknown as Db, close: () => pool.end() }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url !== 'memory://' && !url.startsWith('pglite://')) {
|
||||||
|
throw new Error(`Unsupported DATABASE_URL: expected postgres://, pglite:// or memory://`)
|
||||||
|
}
|
||||||
|
const dataDir = url === 'memory://' ? undefined : url.slice('pglite://'.length)
|
||||||
|
if (dataDir) mkdirSync(dataDir, { recursive: true })
|
||||||
|
const client = new PGlite(dataDir)
|
||||||
|
const db = drizzlePglite({ client, schema, casing: 'snake_case' })
|
||||||
|
await migratePglite(db, { migrationsFolder })
|
||||||
|
return { db: db as unknown as Db, close: () => client.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for a unique-constraint violation from either driver (Drizzle wraps driver errors in `cause`). */
|
||||||
|
export function isUniqueViolation(error: unknown): boolean {
|
||||||
|
let current: unknown = error
|
||||||
|
for (let depth = 0; current && typeof current === 'object' && depth < 5; depth++) {
|
||||||
|
if ((current as { code?: unknown }).code === '23505') return true
|
||||||
|
current = (current as { cause?: unknown }).cause
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
CREATE TYPE "public"."action_kind" AS ENUM('corrective', 'preventive');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."action_status" AS ENUM('open', 'in_progress', 'done', 'verified', 'cancelled');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."area_kind" AS ENUM('building', 'area', 'line', 'other');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."employment_type" AS ENUM('employee', 'temporary', 'contractor', 'visitor');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."incident_status" AS ENUM('reported', 'triage', 'investigation', 'actions', 'verification', 'closed');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."incident_type" AS ENUM('near_miss', 'hazard', 'injury', 'illness', 'property_damage', 'environmental');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."rca_method" AS ENUM('five_whys', 'fishbone', 'other');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."role" AS ENUM('reporter', 'supervisor', 'safety_manager', 'admin');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."root_cause_category" AS ENUM('people', 'process', 'equipment', 'materials', 'environment', 'management');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."severity" AS ENUM('low', 'medium', 'high', 'critical');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."treatment_level" AS ENUM('none', 'first_aid', 'medical_treatment', 'hospitalized', 'fatality');--> statement-breakpoint
|
||||||
|
CREATE TABLE "areas" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"site_id" uuid NOT NULL,
|
||||||
|
"parent_id" uuid,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"kind" "area_kind" DEFAULT 'area' NOT NULL,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "attachments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"incident_id" uuid NOT NULL,
|
||||||
|
"file_name" text NOT NULL,
|
||||||
|
"content_type" text NOT NULL,
|
||||||
|
"size_bytes" integer NOT NULL,
|
||||||
|
"storage_key" text NOT NULL,
|
||||||
|
"uploaded_by_id" uuid,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "attachments_storageKey_unique" UNIQUE("storage_key")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "audit_log" (
|
||||||
|
"id" bigserial PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" uuid NOT NULL,
|
||||||
|
"actor_id" uuid,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"entity_id" text NOT NULL,
|
||||||
|
"action" text NOT NULL,
|
||||||
|
"changes" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "comments" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"incident_id" uuid NOT NULL,
|
||||||
|
"author_id" uuid,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "corrective_actions" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"incident_id" uuid NOT NULL,
|
||||||
|
"root_cause_id" uuid,
|
||||||
|
"kind" "action_kind" DEFAULT 'corrective' NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"assignee_id" uuid,
|
||||||
|
"due_date" date,
|
||||||
|
"status" "action_status" DEFAULT 'open' NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"verified_by_id" uuid,
|
||||||
|
"verified_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "hours_worked" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"site_id" uuid NOT NULL,
|
||||||
|
"month" date NOT NULL,
|
||||||
|
"hours" numeric(12, 2) NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "incident_counters" (
|
||||||
|
"organization_id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"last_number" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "incidents" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"organization_id" uuid NOT NULL,
|
||||||
|
"number" integer NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"type" "incident_type" NOT NULL,
|
||||||
|
"severity" "severity" NOT NULL,
|
||||||
|
"status" "incident_status" DEFAULT 'reported' NOT NULL,
|
||||||
|
"occurred_at" timestamp with time zone NOT NULL,
|
||||||
|
"site_id" uuid NOT NULL,
|
||||||
|
"area_id" uuid,
|
||||||
|
"location_detail" text,
|
||||||
|
"description" text,
|
||||||
|
"immediate_action" text,
|
||||||
|
"reporter_id" uuid,
|
||||||
|
"anonymous" boolean DEFAULT false NOT NULL,
|
||||||
|
"closed_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "injured_persons" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"incident_id" uuid NOT NULL,
|
||||||
|
"user_id" uuid,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"job_title" text,
|
||||||
|
"employment_type" "employment_type" DEFAULT 'employee' NOT NULL,
|
||||||
|
"body_part" text,
|
||||||
|
"nature_of_injury" text,
|
||||||
|
"treatment" "treatment_level" DEFAULT 'none' NOT NULL,
|
||||||
|
"days_away" integer DEFAULT 0 NOT NULL,
|
||||||
|
"days_restricted" integer DEFAULT 0 NOT NULL,
|
||||||
|
"privacy_case" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "investigations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"incident_id" uuid NOT NULL,
|
||||||
|
"lead_id" uuid,
|
||||||
|
"method" "rca_method" DEFAULT 'five_whys' NOT NULL,
|
||||||
|
"summary" text,
|
||||||
|
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "investigations_incidentId_unique" UNIQUE("incident_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "organizations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "root_causes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"investigation_id" uuid NOT NULL,
|
||||||
|
"category" "root_cause_category" NOT NULL,
|
||||||
|
"description" text NOT NULL,
|
||||||
|
"whys" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sites" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"organization_id" uuid NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"address" text,
|
||||||
|
"timezone" text DEFAULT 'UTC' NOT NULL,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"organization_id" uuid NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"role" "role" DEFAULT 'reporter' NOT NULL,
|
||||||
|
"password_hash" text,
|
||||||
|
"active" boolean DEFAULT true NOT NULL,
|
||||||
|
"last_login_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "areas" ADD CONSTRAINT "areas_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "areas" ADD CONSTRAINT "areas_parent_id_areas_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."areas"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_uploaded_by_id_users_id_fk" FOREIGN KEY ("uploaded_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_root_cause_id_root_causes_id_fk" FOREIGN KEY ("root_cause_id") REFERENCES "public"."root_causes"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "corrective_actions" ADD CONSTRAINT "corrective_actions_verified_by_id_users_id_fk" FOREIGN KEY ("verified_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "hours_worked" ADD CONSTRAINT "hours_worked_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "incident_counters" ADD CONSTRAINT "incident_counters_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_area_id_areas_id_fk" FOREIGN KEY ("area_id") REFERENCES "public"."areas"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD CONSTRAINT "injured_persons_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD CONSTRAINT "injured_persons_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_incident_id_incidents_id_fk" FOREIGN KEY ("incident_id") REFERENCES "public"."incidents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_lead_id_users_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "root_causes" ADD CONSTRAINT "root_causes_investigation_id_investigations_id_fk" FOREIGN KEY ("investigation_id") REFERENCES "public"."investigations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sites" ADD CONSTRAINT "sites_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "areas_site_idx" ON "areas" USING btree ("site_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "areas_parent_idx" ON "areas" USING btree ("parent_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "attachments_incident_idx" ON "attachments" USING btree ("incident_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "audit_log_entity_idx" ON "audit_log" USING btree ("organization_id","entity_type","entity_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "comments_incident_idx" ON "comments" USING btree ("incident_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "corrective_actions_incident_idx" ON "corrective_actions" USING btree ("incident_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "corrective_actions_assignee_status_idx" ON "corrective_actions" USING btree ("assignee_id","status");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "hours_worked_site_month_unique" ON "hours_worked" USING btree ("site_id","month");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "incidents_organization_number_unique" ON "incidents" USING btree ("organization_id","number");--> statement-breakpoint
|
||||||
|
CREATE INDEX "incidents_organization_occurred_idx" ON "incidents" USING btree ("organization_id","occurred_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "incidents_organization_status_idx" ON "incidents" USING btree ("organization_id","status");--> statement-breakpoint
|
||||||
|
CREATE INDEX "incidents_reporter_idx" ON "incidents" USING btree ("reporter_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "injured_persons_incident_idx" ON "injured_persons" USING btree ("incident_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "root_causes_investigation_idx" ON "root_causes" USING btree ("investigation_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "sites_organization_name_unique" ON "sites" USING btree ("organization_id","name");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");--> statement-breakpoint
|
||||||
|
CREATE INDEX "users_organization_idx" ON "users" USING btree ("organization_id");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TYPE "public"."auth_token_purpose" AS ENUM('magic_link', 'password_reset');--> statement-breakpoint
|
||||||
|
CREATE TABLE "auth_tokens" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"purpose" "auth_token_purpose" NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"used_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "corrective_actions" ADD COLUMN "reminder_sent_on" date;--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_tokens" ADD CONSTRAINT "auth_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "auth_tokens_user_idx" ON "auth_tokens" USING btree ("user_id");
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TYPE "public"."report_channel" AS ENUM('web', 'kiosk');--> statement-breakpoint
|
||||||
|
CREATE TABLE "kiosks" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"organization_id" uuid NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"site_id" uuid,
|
||||||
|
"created_by_id" uuid,
|
||||||
|
"last_used_at" timestamp with time zone,
|
||||||
|
"revoked_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "incidents" ADD COLUMN "reporter_name" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "incidents" ADD COLUMN "channel" "report_channel" DEFAULT 'web' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "incidents" ADD COLUMN "kiosk_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "incidents" ADD COLUMN "client_id" uuid;--> statement-breakpoint
|
||||||
|
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "kiosks_token_unique" ON "kiosks" USING btree ("token");--> statement-breakpoint
|
||||||
|
CREATE INDEX "kiosks_organization_idx" ON "kiosks" USING btree ("organization_id");--> statement-breakpoint
|
||||||
|
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_kiosk_id_kiosks_id_fk" FOREIGN KEY ("kiosk_id") REFERENCES "public"."kiosks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "incidents_organization_client_id_unique" ON "incidents" USING btree ("organization_id","client_id");
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
CREATE TYPE "public"."osha_case_type" AS ENUM('injury', 'skin_disorder', 'respiratory', 'poisoning', 'hearing_loss', 'other_illness');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."osha_outcome" AS ENUM('death', 'days_away', 'restricted', 'other');--> statement-breakpoint
|
||||||
|
CREATE TYPE "public"."osha_special_case" AS ENUM('none', 'needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis');--> statement-breakpoint
|
||||||
|
ALTER TABLE "hours_worked" ADD COLUMN "average_employees" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "case_number" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "work_related" boolean;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "new_case" boolean;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "died" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "date_of_death" date;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "days_away_from_work" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "restricted_or_transfer" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "medical_treatment" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "loss_of_consciousness" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "significant_diagnosis" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "special_case" "osha_special_case" DEFAULT 'none' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "case_type" "osha_case_type" DEFAULT 'injury' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "recordable" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "outcome" "osha_outcome";--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "inpatient_hospitalization" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "amputation" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "loss_of_eye" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "reported_to_osha_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "employee_address" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "date_of_birth" date;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "date_of_hire" date;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "sex" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "physician_name" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "treatment_facility" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "treated_in_emergency_room" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "time_started_work" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "activity_before" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "what_happened" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "injured_persons" ADD COLUMN "object_substance" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sites" ADD COLUMN "street_address" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sites" ADD COLUMN "city" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sites" ADD COLUMN "state" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sites" ADD COLUMN "zip" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sites" ADD COLUMN "naics_code" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sites" ADD COLUMN "industry_description" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sites" ADD COLUMN "ein" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "sites" ADD COLUMN "establishment_type" integer DEFAULT 1 NOT NULL;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE "saved_views" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"organization_id" uuid NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"page" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"filters" jsonb NOT NULL,
|
||||||
|
"shared" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "saved_views_organization_page_idx" ON "saved_views" USING btree ("organization_id","page");
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791306770507,
|
||||||
|
"tag": "0000_initial",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791309275942,
|
||||||
|
"tag": "0001_workflow_and_email",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791310953907,
|
||||||
|
"tag": "0002_kiosks_and_offline",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791312050891,
|
||||||
|
"tag": "0003_osha_recordkeeping",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791315152348,
|
||||||
|
"tag": "0004_saved_views",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { bigserial, boolean, date, index, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid, type AnyPgColumn } from 'drizzle-orm/pg-core'
|
||||||
|
import * as vocab from '../../shared/enums.ts'
|
||||||
|
|
||||||
|
export const roleEnum = pgEnum('role', vocab.roles)
|
||||||
|
export const incidentTypeEnum = pgEnum('incident_type', vocab.incidentTypes)
|
||||||
|
export const severityEnum = pgEnum('severity', vocab.severities)
|
||||||
|
export const incidentStatusEnum = pgEnum('incident_status', vocab.incidentStatuses)
|
||||||
|
export const areaKindEnum = pgEnum('area_kind', vocab.areaKinds)
|
||||||
|
export const employmentTypeEnum = pgEnum('employment_type', vocab.employmentTypes)
|
||||||
|
export const treatmentLevelEnum = pgEnum('treatment_level', vocab.treatmentLevels)
|
||||||
|
export const rcaMethodEnum = pgEnum('rca_method', vocab.rcaMethods)
|
||||||
|
export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCauseCategories)
|
||||||
|
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
|
||||||
|
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
|
||||||
|
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
|
||||||
|
export const reportChannelEnum = pgEnum('report_channel', ['web', 'kiosk'])
|
||||||
|
export const oshaCaseTypeEnum = pgEnum('osha_case_type', vocab.oshaCaseTypes)
|
||||||
|
export const oshaOutcomeEnum = pgEnum('osha_outcome', vocab.oshaOutcomes)
|
||||||
|
export const oshaSpecialCaseEnum = pgEnum('osha_special_case', vocab.oshaSpecialCases)
|
||||||
|
|
||||||
|
const id = () => uuid().primaryKey().defaultRandom()
|
||||||
|
const timestamptz = () => timestamp({ withTimezone: true })
|
||||||
|
const createdAt = () => timestamptz().notNull().defaultNow()
|
||||||
|
const updatedAt = () =>
|
||||||
|
timestamptz()
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
|
||||||
|
export const organizations = pgTable('organizations', {
|
||||||
|
id: id(),
|
||||||
|
name: text().notNull(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const users = pgTable(
|
||||||
|
'users',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
organizationId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
/** Stored lower-cased. */
|
||||||
|
email: text().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
|
role: roleEnum().notNull().default('reporter'),
|
||||||
|
passwordHash: text(),
|
||||||
|
active: boolean().notNull().default(true),
|
||||||
|
lastLoginAt: timestamptz(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('users_email_unique').on(t.email), index('users_organization_idx').on(t.organizationId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const sessions = pgTable(
|
||||||
|
'sessions',
|
||||||
|
{
|
||||||
|
/** SHA-256 of the session token; the token itself is never stored. */
|
||||||
|
id: text().primaryKey(),
|
||||||
|
userId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
expiresAt: timestamptz().notNull(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('sessions_user_idx').on(t.userId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Single-use links sent by email (magic sign-in, password reset/invite). */
|
||||||
|
export const authTokens = pgTable(
|
||||||
|
'auth_tokens',
|
||||||
|
{
|
||||||
|
/** SHA-256 of the token sent in the link. */
|
||||||
|
id: text().primaryKey(),
|
||||||
|
userId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
purpose: authTokenPurposeEnum().notNull(),
|
||||||
|
expiresAt: timestamptz().notNull(),
|
||||||
|
usedAt: timestamptz(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('auth_tokens_user_idx').on(t.userId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const sites = pgTable(
|
||||||
|
'sites',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
organizationId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
name: text().notNull(),
|
||||||
|
address: text(),
|
||||||
|
timezone: text().notNull().default('UTC'),
|
||||||
|
// OSHA establishment details (Form 300A and the ITA upload)
|
||||||
|
streetAddress: text(),
|
||||||
|
city: text(),
|
||||||
|
state: text(),
|
||||||
|
zip: text(),
|
||||||
|
naicsCode: text(),
|
||||||
|
industryDescription: text(),
|
||||||
|
ein: text(),
|
||||||
|
/** ITA establishment_type: 1 private, 2 state government, 3 local government. */
|
||||||
|
establishmentType: integer().notNull().default(1),
|
||||||
|
archivedAt: timestamptz(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('sites_organization_name_unique').on(t.organizationId, t.name)],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Location tree within a site: building → area → line. */
|
||||||
|
export const areas = pgTable(
|
||||||
|
'areas',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
siteId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => sites.id, { onDelete: 'cascade' }),
|
||||||
|
parentId: uuid().references((): AnyPgColumn => areas.id, { onDelete: 'cascade' }),
|
||||||
|
name: text().notNull(),
|
||||||
|
kind: areaKindEnum().notNull().default('area'),
|
||||||
|
archivedAt: timestamptz(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reporting links let people without accounts file reports: on a shared kiosk tablet, or by scanning
|
||||||
|
* a QR code. The token only allows creating reports, so it is stored as-is to let admins reprint QR codes.
|
||||||
|
*/
|
||||||
|
export const kiosks = pgTable(
|
||||||
|
'kiosks',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
organizationId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
name: text().notNull(),
|
||||||
|
token: text().notNull(),
|
||||||
|
/** Limits the link to one site; null allows any. */
|
||||||
|
siteId: uuid().references(() => sites.id, { onDelete: 'set null' }),
|
||||||
|
createdById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
lastUsedAt: timestamptz(),
|
||||||
|
revokedAt: timestamptz(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('kiosks_token_unique').on(t.token), index('kiosks_organization_idx').on(t.organizationId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Per-organization counter for gap-free, human-friendly incident numbers. */
|
||||||
|
export const incidentCounters = pgTable('incident_counters', {
|
||||||
|
organizationId: uuid()
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
lastNumber: integer().notNull().default(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const incidents = pgTable(
|
||||||
|
'incidents',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
organizationId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
number: integer().notNull(),
|
||||||
|
title: text().notNull(),
|
||||||
|
type: incidentTypeEnum().notNull(),
|
||||||
|
severity: severityEnum().notNull(),
|
||||||
|
status: incidentStatusEnum().notNull().default('reported'),
|
||||||
|
occurredAt: timestamptz().notNull(),
|
||||||
|
siteId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => sites.id),
|
||||||
|
areaId: uuid().references(() => areas.id, { onDelete: 'set null' }),
|
||||||
|
locationDetail: text(),
|
||||||
|
description: text(),
|
||||||
|
immediateAction: text(),
|
||||||
|
/** Null when reported anonymously. */
|
||||||
|
reporterId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
anonymous: boolean().notNull().default(false),
|
||||||
|
/** Name typed by someone reporting without an account (kiosk/QR). */
|
||||||
|
reporterName: text(),
|
||||||
|
channel: reportChannelEnum().notNull().default('web'),
|
||||||
|
kioskId: uuid().references(() => kiosks.id, { onDelete: 'set null' }),
|
||||||
|
/** Generated on the reporting device so retries after a lost response don't create duplicates. */
|
||||||
|
clientId: uuid(),
|
||||||
|
closedAt: timestamptz(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
|
||||||
|
uniqueIndex('incidents_organization_client_id_unique').on(t.organizationId, t.clientId),
|
||||||
|
index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt),
|
||||||
|
index('incidents_organization_status_idx').on(t.organizationId, t.status),
|
||||||
|
index('incidents_reporter_idx').on(t.reporterId),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const injuredPersons = pgTable(
|
||||||
|
'injured_persons',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
incidentId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||||
|
/** Set when the person has a user account. */
|
||||||
|
userId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
name: text().notNull(),
|
||||||
|
jobTitle: text(),
|
||||||
|
employmentType: employmentTypeEnum().notNull().default('employee'),
|
||||||
|
bodyPart: text(),
|
||||||
|
natureOfInjury: text(),
|
||||||
|
treatment: treatmentLevelEnum().notNull().default('none'),
|
||||||
|
daysAway: integer().notNull().default(0),
|
||||||
|
daysRestricted: integer().notNull().default(0),
|
||||||
|
/** OSHA privacy case: name withheld from the 300 log. */
|
||||||
|
privacyCase: boolean().notNull().default(false),
|
||||||
|
|
||||||
|
// OSHA recordability (29 CFR 1904). Null answers mean "not assessed yet".
|
||||||
|
caseNumber: text(),
|
||||||
|
workRelated: boolean(),
|
||||||
|
newCase: boolean(),
|
||||||
|
died: boolean().notNull().default(false),
|
||||||
|
dateOfDeath: date({ mode: 'string' }),
|
||||||
|
daysAwayFromWork: boolean().notNull().default(false),
|
||||||
|
restrictedOrTransfer: boolean().notNull().default(false),
|
||||||
|
medicalTreatment: boolean().notNull().default(false),
|
||||||
|
lossOfConsciousness: boolean().notNull().default(false),
|
||||||
|
significantDiagnosis: boolean().notNull().default(false),
|
||||||
|
specialCase: oshaSpecialCaseEnum().notNull().default('none'),
|
||||||
|
caseType: oshaCaseTypeEnum().notNull().default('injury'),
|
||||||
|
/** Derived from the answers above with shared/osha.ts, stored for reporting. */
|
||||||
|
recordable: boolean().notNull().default(false),
|
||||||
|
outcome: oshaOutcomeEnum(),
|
||||||
|
|
||||||
|
// Severe injury reporting (1904.39)
|
||||||
|
inpatientHospitalization: boolean().notNull().default(false),
|
||||||
|
amputation: boolean().notNull().default(false),
|
||||||
|
lossOfEye: boolean().notNull().default(false),
|
||||||
|
reportedToOshaAt: timestamptz(),
|
||||||
|
|
||||||
|
// Form 301 details
|
||||||
|
employeeAddress: text(),
|
||||||
|
dateOfBirth: date({ mode: 'string' }),
|
||||||
|
dateOfHire: date({ mode: 'string' }),
|
||||||
|
sex: text(),
|
||||||
|
physicianName: text(),
|
||||||
|
treatmentFacility: text(),
|
||||||
|
treatedInEmergencyRoom: boolean().notNull().default(false),
|
||||||
|
timeStartedWork: text(),
|
||||||
|
activityBefore: text(),
|
||||||
|
whatHappened: text(),
|
||||||
|
objectSubstance: text(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('injured_persons_incident_idx').on(t.incidentId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const attachments = pgTable(
|
||||||
|
'attachments',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
incidentId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||||
|
fileName: text().notNull(),
|
||||||
|
contentType: text().notNull(),
|
||||||
|
sizeBytes: integer().notNull(),
|
||||||
|
storageKey: text().notNull().unique(),
|
||||||
|
uploadedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('attachments_incident_idx').on(t.incidentId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const investigations = pgTable('investigations', {
|
||||||
|
id: id(),
|
||||||
|
incidentId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.unique()
|
||||||
|
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||||
|
leadId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
method: rcaMethodEnum().notNull().default('five_whys'),
|
||||||
|
summary: text(),
|
||||||
|
startedAt: timestamptz().notNull().defaultNow(),
|
||||||
|
completedAt: timestamptz(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const rootCauses = pgTable(
|
||||||
|
'root_causes',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
investigationId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => investigations.id, { onDelete: 'cascade' }),
|
||||||
|
category: rootCauseCategoryEnum().notNull(),
|
||||||
|
description: text().notNull(),
|
||||||
|
/** 5 Whys chain, first "why" first. */
|
||||||
|
whys: jsonb().$type<string[]>().notNull().default([]),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('root_causes_investigation_idx').on(t.investigationId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const correctiveActions = pgTable(
|
||||||
|
'corrective_actions',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
incidentId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||||
|
rootCauseId: uuid().references(() => rootCauses.id, { onDelete: 'set null' }),
|
||||||
|
kind: actionKindEnum().notNull().default('corrective'),
|
||||||
|
title: text().notNull(),
|
||||||
|
description: text(),
|
||||||
|
assigneeId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
dueDate: date({ mode: 'string' }),
|
||||||
|
status: actionStatusEnum().notNull().default('open'),
|
||||||
|
completedAt: timestamptz(),
|
||||||
|
verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
verifiedAt: timestamptz(),
|
||||||
|
/** Date the last due/overdue reminder was emailed, so it goes out at most once a day. */
|
||||||
|
reminderSentOn: date({ mode: 'string' }),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('corrective_actions_incident_idx').on(t.incidentId), index('corrective_actions_assignee_status_idx').on(t.assigneeId, t.status)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const comments = pgTable(
|
||||||
|
'comments',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
incidentId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => incidents.id, { onDelete: 'cascade' }),
|
||||||
|
authorId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
body: text().notNull(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('comments_incident_idx').on(t.incidentId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
export const auditLog = pgTable(
|
||||||
|
'audit_log',
|
||||||
|
{
|
||||||
|
id: bigserial({ mode: 'number' }).primaryKey(),
|
||||||
|
organizationId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
actorId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
entityType: text().notNull(),
|
||||||
|
entityId: text().notNull(),
|
||||||
|
action: text().notNull(),
|
||||||
|
/** `{ field: [before, after] }` for updates, the created record for creates. */
|
||||||
|
changes: jsonb(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('audit_log_entity_idx').on(t.organizationId, t.entityType, t.entityId)],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Monthly hours worked per site, for TRIR / DART rate calculations. */
|
||||||
|
export const hoursWorked = pgTable(
|
||||||
|
'hours_worked',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
siteId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => sites.id, { onDelete: 'cascade' }),
|
||||||
|
/** First day of the month, e.g. `2026-10-01`. */
|
||||||
|
month: date({ mode: 'string' }).notNull(),
|
||||||
|
hours: numeric({ precision: 12, scale: 2 }).notNull(),
|
||||||
|
/** Average number of employees on the payroll that month (for the annual average). */
|
||||||
|
averageEmployees: integer(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
updatedAt: updatedAt(),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('hours_worked_site_month_unique').on(t.siteId, t.month)],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Named filter sets for the Analytics and Incidents pages; private, or shared with the organization. */
|
||||||
|
export const savedViews = pgTable(
|
||||||
|
'saved_views',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
organizationId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => organizations.id),
|
||||||
|
userId: uuid()
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
page: text().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
|
filters: jsonb().$type<Record<string, string>>().notNull(),
|
||||||
|
shared: boolean().notNull().default(false),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(t) => [index('saved_views_organization_page_idx').on(t.organizationId, t.page)],
|
||||||
|
)
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// Loads demo data into an empty database: `npm run db:seed`. Not for production use.
|
||||||
|
import { hashPassword } from '../auth/password.ts'
|
||||||
|
import { loadConfig } from '../config.ts'
|
||||||
|
import { openDatabase } from './client.ts'
|
||||||
|
import { assessRecordability } from '../../shared/osha.ts'
|
||||||
|
import { auditLog, areas, correctiveActions, hoursWorked, incidentCounters, incidents, injuredPersons, investigations, organizations, rootCauses, sites, users } from './schema.ts'
|
||||||
|
|
||||||
|
const DEMO_PASSWORD = 'safetylog-demo'
|
||||||
|
|
||||||
|
const config = loadConfig()
|
||||||
|
const { db, close } = await openDatabase(config.databaseUrl)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ((await db.select({ id: users.id }).from(users).limit(1)).length > 0) {
|
||||||
|
console.error('Database already has users; seeding is only for empty databases.')
|
||||||
|
process.exitCode = 1
|
||||||
|
} else {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const [org] = await tx.insert(organizations).values({ name: 'Northstar Co.' }).returning()
|
||||||
|
const passwordHash = await hashPassword(DEMO_PASSWORD)
|
||||||
|
const people = await tx
|
||||||
|
.insert(users)
|
||||||
|
.values([
|
||||||
|
{ name: 'Jordan Lee', email: 'admin@example.com', role: 'admin' as const },
|
||||||
|
{ name: 'Priya Shah', email: 'manager@example.com', role: 'safety_manager' as const },
|
||||||
|
{ name: 'Sam Rivera', email: 'supervisor@example.com', role: 'supervisor' as const },
|
||||||
|
{ name: 'Maya Chen', email: 'reporter@example.com', role: 'reporter' as const },
|
||||||
|
].map((u) => ({ ...u, organizationId: org.id, passwordHash })))
|
||||||
|
.returning()
|
||||||
|
const person = (email: string) => people.find((p) => p.email === email)!.id
|
||||||
|
|
||||||
|
const layout: Record<string, string[]> = {
|
||||||
|
'North Campus': ['Building A', 'Warehouse 04', 'Lab 2'],
|
||||||
|
'South Campus': ['Fabrication 02', 'Yard', 'Office block'],
|
||||||
|
'West Campus': ['Warehouse 01', 'Loading dock'],
|
||||||
|
}
|
||||||
|
const areaIds: Record<string, { siteId: string; areaId: string }> = {}
|
||||||
|
for (const [siteName, buildings] of Object.entries(layout)) {
|
||||||
|
const [site] = await tx.insert(sites).values({ organizationId: org.id, name: siteName }).returning()
|
||||||
|
for (const name of buildings) {
|
||||||
|
const [area] = await tx.insert(areas).values({ siteId: site.id, name, kind: 'building' }).returning()
|
||||||
|
areaIds[name] = { siteId: site.id, areaId: area.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysAgo = (days: number) => new Date(Date.now() - days * 86_400_000)
|
||||||
|
const demo = [
|
||||||
|
{ title: 'Damaged guardrail on mezzanine', type: 'property_damage', severity: 'low', status: 'closed', building: 'Warehouse 01', locationDetail: 'Mezzanine east', reporter: 'manager@example.com', days: 9 },
|
||||||
|
{ title: 'Hand laceration during material handling', type: 'injury', severity: 'high', status: 'investigation', building: 'Fabrication 02', locationDetail: 'Line 3', reporter: 'supervisor@example.com', days: 7 },
|
||||||
|
{ title: 'Wet floor near employee entrance', type: 'hazard', severity: 'medium', status: 'actions', building: 'Building A', locationDetail: 'Ground floor lobby', reporter: 'reporter@example.com', days: 6 },
|
||||||
|
{ title: 'Forklift reversed into pallet stack', type: 'near_miss', severity: 'high', status: 'triage', building: 'Warehouse 04', locationDetail: 'Loading bay C', reporter: 'admin@example.com', days: 5 },
|
||||||
|
] as const
|
||||||
|
const created = await tx
|
||||||
|
.insert(incidents)
|
||||||
|
.values(
|
||||||
|
demo.map((item, index) => ({
|
||||||
|
organizationId: org.id,
|
||||||
|
number: index + 1,
|
||||||
|
title: item.title,
|
||||||
|
type: item.type,
|
||||||
|
severity: item.severity,
|
||||||
|
status: item.status,
|
||||||
|
occurredAt: daysAgo(item.days),
|
||||||
|
createdAt: new Date(daysAgo(item.days).getTime() + 2 * 3_600_000),
|
||||||
|
...areaIds[item.building],
|
||||||
|
locationDetail: item.locationDetail,
|
||||||
|
reporterId: person(item.reporter),
|
||||||
|
closedAt: item.status === 'closed' ? daysAgo(item.days - 3) : null,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.returning()
|
||||||
|
await tx.insert(auditLog).values(
|
||||||
|
created.map((incident) => ({
|
||||||
|
organizationId: org.id,
|
||||||
|
actorId: incident.reporterId,
|
||||||
|
entityType: 'incident',
|
||||||
|
entityId: incident.id,
|
||||||
|
action: 'create',
|
||||||
|
changes: { number: incident.number, title: incident.title },
|
||||||
|
createdAt: incident.createdAt,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
// A year of history so dashboards and the OSHA log have something to show
|
||||||
|
const lastNumber = await seedHistory(tx as unknown as typeof db, org.id, demo.length, areaIds, people.map((p) => p.id))
|
||||||
|
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber })
|
||||||
|
})
|
||||||
|
console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic pseudo-random numbers, so every seed produces the same demo. */
|
||||||
|
function random(seed: number) {
|
||||||
|
let state = seed
|
||||||
|
return () => {
|
||||||
|
state = (state * 1_664_525 + 1_013_904_223) % 2 ** 32
|
||||||
|
return state / 2 ** 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedHistory(
|
||||||
|
database: typeof db,
|
||||||
|
organizationId: string,
|
||||||
|
startNumber: number,
|
||||||
|
areaIds: Record<string, { siteId: string; areaId: string }>,
|
||||||
|
userIds: string[],
|
||||||
|
): Promise<number> {
|
||||||
|
const rand = random(42)
|
||||||
|
const pick = <T>(items: readonly T[]) => items[Math.floor(rand() * items.length)]
|
||||||
|
const day = 86_400_000
|
||||||
|
const now = Date.now()
|
||||||
|
const locations = Object.values(areaIds)
|
||||||
|
// Near misses and hazards outnumber injuries, as in a healthy reporting culture
|
||||||
|
const types = ['near_miss', 'near_miss', 'near_miss', 'hazard', 'hazard', 'hazard', 'injury', 'injury', 'property_damage', 'environmental', 'illness'] as const
|
||||||
|
// Busier around shift changes (06–08, 14–16) and mid-shift
|
||||||
|
const hours = [6, 7, 7, 9, 10, 11, 13, 14, 14, 15, 15, 16, 19, 22, 2]
|
||||||
|
const titles = {
|
||||||
|
near_miss: ['Forklift nearly struck pedestrian', 'Load slipped from pallet jack', 'Unsecured ladder almost fell', 'Dropped tool from mezzanine'],
|
||||||
|
hazard: ['Oil leak under press', 'Blocked emergency exit', 'Damaged guard on conveyor', 'Frayed extension cord', 'Missing machine guard'],
|
||||||
|
injury: ['Hand laceration from sheet metal', 'Back strain lifting boxes', 'Slip on wet floor', 'Pinched finger in die'],
|
||||||
|
illness: ['Dermatitis from cutting fluid'],
|
||||||
|
property_damage: ['Forklift hit racking', 'Damaged dock door'],
|
||||||
|
environmental: ['Hydraulic fluid spill', 'Coolant leak to drain'],
|
||||||
|
} as const
|
||||||
|
const categories = ['people', 'process', 'equipment', 'materials', 'environment', 'management'] as const
|
||||||
|
|
||||||
|
let number = startNumber
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
const type = pick(types)
|
||||||
|
const occurred = new Date(now - Math.floor(rand() * 360 + 3) * day)
|
||||||
|
occurred.setHours(pick(hours), Math.floor(rand() * 60), 0, 0)
|
||||||
|
const created = new Date(occurred.getTime() + (1 + rand() * 6) * 3_600_000)
|
||||||
|
const age = (now - occurred.getTime()) / day
|
||||||
|
const status = age > 60 ? (rand() < 0.85 ? 'closed' : 'actions') : age > 20 ? pick(['investigation', 'actions', 'verification', 'closed'] as const) : pick(['reported', 'triage', 'investigation'] as const)
|
||||||
|
const closedAt = status === 'closed' ? new Date(created.getTime() + (5 + rand() * 35) * day) : null
|
||||||
|
number++
|
||||||
|
const [incident] = await database
|
||||||
|
.insert(incidents)
|
||||||
|
.values({
|
||||||
|
organizationId,
|
||||||
|
number,
|
||||||
|
title: pick(titles[type]),
|
||||||
|
type,
|
||||||
|
severity: type === 'injury' ? pick(['medium', 'high', 'high'] as const) : pick(['low', 'medium', 'medium', 'high'] as const),
|
||||||
|
status,
|
||||||
|
occurredAt: occurred,
|
||||||
|
createdAt: created,
|
||||||
|
closedAt: closedAt && closedAt.getTime() < now ? closedAt : null,
|
||||||
|
...pick(locations),
|
||||||
|
reporterId: pick(userIds),
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
const auditRows: (typeof auditLog.$inferInsert)[] = [
|
||||||
|
{ organizationId, actorId: incident.reporterId, entityType: 'incident', entityId: incident.id, action: 'create', changes: { number, title: incident.title }, createdAt: created },
|
||||||
|
]
|
||||||
|
if (status !== 'reported') {
|
||||||
|
// Most reports are triaged within a day; the team got quicker over the year
|
||||||
|
const delayHours = rand() < 0.55 + (1 - age / 365) * 0.35 ? 2 + rand() * 18 : 26 + rand() * 70
|
||||||
|
auditRows.push({ organizationId, actorId: pick(userIds), entityType: 'incident', entityId: incident.id, action: 'status', changes: { status: ['reported', 'triage'] }, createdAt: new Date(created.getTime() + delayHours * 3_600_000) })
|
||||||
|
}
|
||||||
|
await database.insert(auditLog).values(auditRows)
|
||||||
|
|
||||||
|
if (['investigation', 'actions', 'verification', 'closed'].includes(status) && type !== 'near_miss') {
|
||||||
|
const [investigation] = await database.insert(investigations).values({ incidentId: incident.id, method: 'five_whys', startedAt: created }).returning()
|
||||||
|
const causes = await database
|
||||||
|
.insert(rootCauses)
|
||||||
|
.values(Array.from({ length: 1 + Math.floor(rand() * 2) }, () => ({ investigationId: investigation.id, category: pick(categories), description: 'Identified during investigation' })))
|
||||||
|
.returning()
|
||||||
|
const due = new Date(created.getTime() + 21 * day)
|
||||||
|
const done = status === 'closed' || status === 'verification' || rand() < 0.4
|
||||||
|
const completed = new Date(created.getTime() + (8 + rand() * 30) * day)
|
||||||
|
await database.insert(correctiveActions).values({
|
||||||
|
incidentId: incident.id,
|
||||||
|
rootCauseId: causes[0].id,
|
||||||
|
title: 'Fix the cause and brief the team',
|
||||||
|
assigneeId: pick(userIds),
|
||||||
|
dueDate: due.toISOString().slice(0, 10),
|
||||||
|
status: done && completed.getTime() < now ? (status === 'closed' ? 'verified' : 'done') : 'open',
|
||||||
|
completedAt: done && completed.getTime() < now ? completed : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((type === 'injury' || type === 'illness') && rand() < 0.6) {
|
||||||
|
const answers = {
|
||||||
|
employmentType: 'employee' as const,
|
||||||
|
workRelated: true,
|
||||||
|
newCase: true,
|
||||||
|
died: false,
|
||||||
|
daysAwayFromWork: rand() < 0.3,
|
||||||
|
restrictedOrTransfer: rand() < 0.4,
|
||||||
|
medicalTreatment: rand() < 0.7,
|
||||||
|
lossOfConsciousness: false,
|
||||||
|
significantDiagnosis: false,
|
||||||
|
specialCase: 'none' as const,
|
||||||
|
}
|
||||||
|
const verdict = assessRecordability(answers)
|
||||||
|
await database.insert(injuredPersons).values({
|
||||||
|
incidentId: incident.id,
|
||||||
|
caseNumber: `INC-${String(number).padStart(4, '0')}`,
|
||||||
|
name: pick(['Alex Moreno', 'Jamie Patel', 'Chris Nguyen', 'Taylor Brooks', 'Morgan Diaz']),
|
||||||
|
jobTitle: pick(['Press operator', 'Material handler', 'Maintenance technician', 'Assembler']),
|
||||||
|
natureOfInjury: incident.title,
|
||||||
|
caseType: type === 'illness' ? 'skin_disorder' : 'injury',
|
||||||
|
...answers,
|
||||||
|
daysAway: answers.daysAwayFromWork ? 1 + Math.floor(rand() * 10) : 0,
|
||||||
|
daysRestricted: answers.restrictedOrTransfer ? 2 + Math.floor(rand() * 14) : 0,
|
||||||
|
recordable: verdict.status === 'recordable',
|
||||||
|
outcome: verdict.outcome,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly hours worked and headcount for every site, for the last 13 months
|
||||||
|
const siteIds = [...new Set(locations.map((l) => l.siteId))]
|
||||||
|
const today = new Date()
|
||||||
|
for (const siteId of siteIds) {
|
||||||
|
for (let m = 12; m >= 0; m--) {
|
||||||
|
const month = new Date(today.getFullYear(), today.getMonth() - m, 1)
|
||||||
|
const employees = 70 + Math.floor(rand() * 30)
|
||||||
|
await database.insert(hoursWorked).values({
|
||||||
|
siteId,
|
||||||
|
month: `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}-01`,
|
||||||
|
hours: String(employees * 168),
|
||||||
|
averageEmployees: employees,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return number
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { serve } from '@hono/node-server'
|
||||||
|
import { serveStatic } from '@hono/node-server/serve-static'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { createApp } from './app.ts'
|
||||||
|
import { loadConfig } from './config.ts'
|
||||||
|
import { openDatabase } from './db/client.ts'
|
||||||
|
import { createMailer } from './mail.ts'
|
||||||
|
import { Notifier } from './notifications.ts'
|
||||||
|
import { FileStorage } from './storage.ts'
|
||||||
|
|
||||||
|
const config = loadConfig()
|
||||||
|
const database = await openDatabase(config.databaseUrl)
|
||||||
|
const notifier = new Notifier(database.db, createMailer(config), config)
|
||||||
|
const app = createApp({ db: database.db, config, notifier, storage: new FileStorage(config.uploadDir) })
|
||||||
|
|
||||||
|
// Hourly check for corrective actions that are due or overdue; each gets at most one reminder a day.
|
||||||
|
const sendReminders = () => notifier.sendDueReminders().catch((error) => console.error('[reminders] failed:', error))
|
||||||
|
const reminderTimer = setInterval(sendReminders, 60 * 60 * 1000)
|
||||||
|
setTimeout(sendReminders, 30_000).unref()
|
||||||
|
|
||||||
|
// In production the server also serves the built web app.
|
||||||
|
if (existsSync(join(config.staticDir, 'index.html'))) {
|
||||||
|
app.use(
|
||||||
|
'/*',
|
||||||
|
serveStatic({
|
||||||
|
root: config.staticDir,
|
||||||
|
onFound: (path, c) => {
|
||||||
|
if (path.includes('/assets/')) c.header('Cache-Control', 'public, max-age=31536000, immutable')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
app.get('*', serveStatic({ path: join(config.staticDir, 'index.html') })) // client-side routes
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = serve({ fetch: app.fetch, port: config.port }, ({ port }) => {
|
||||||
|
console.log(`Open SafetyLog EHS listening on port ${port}, public URL ${config.publicUrl} (database: ${config.databaseUrl.replace(/\/\/[^@]*@/, '//***@')})`)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
clearInterval(reminderTimer)
|
||||||
|
server.close(async () => {
|
||||||
|
await notifier.flush()
|
||||||
|
await database.close()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { and, eq, gte, inArray, isNotNull, lt, min } from 'drizzle-orm'
|
||||||
|
import type { AnalyticsStats, Kpi, TypeGroup } from '../../shared/api-types.ts'
|
||||||
|
import { hasRole, type IncidentType } from '../../shared/enums.ts'
|
||||||
|
import { incidenceRate } from '../../shared/osha.ts'
|
||||||
|
import type { SessionUser } from '../auth/sessions.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { areas, auditLog, correctiveActions, hoursWorked, incidents, injuredPersons, investigations, rootCauses, sites } from '../db/schema.ts'
|
||||||
|
import { addDays, localDate } from './dates.ts'
|
||||||
|
import { visibleTo } from './incidents.ts'
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000
|
||||||
|
|
||||||
|
export const typeGroup: Record<IncidentType, TypeGroup> = {
|
||||||
|
near_miss: 'hazards',
|
||||||
|
hazard: 'hazards',
|
||||||
|
injury: 'injuries',
|
||||||
|
illness: 'injuries',
|
||||||
|
property_damage: 'damage',
|
||||||
|
environmental: 'damage',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFilters {
|
||||||
|
/** Inclusive start date, `YYYY-MM-DD` (server time zone). */
|
||||||
|
from: string
|
||||||
|
/** Inclusive end date. */
|
||||||
|
to: string
|
||||||
|
siteId?: string
|
||||||
|
type?: IncidentType
|
||||||
|
}
|
||||||
|
|
||||||
|
const startOf = (isoDate: string) => new Date(`${isoDate}T00:00:00`)
|
||||||
|
const monthKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const ratio = (part: number, whole: number) => (whole > 0 ? part / whole : null)
|
||||||
|
const round1 = (value: number | null) => (value === null ? null : Math.round(value * 10) / 10)
|
||||||
|
|
||||||
|
/** Monday of the week containing `date`, local time. */
|
||||||
|
function weekStart(date: Date): Date {
|
||||||
|
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buckets covering [from, to]: weeks for ranges up to ~3 months, otherwise months. */
|
||||||
|
export function buckets(from: string, to: string): { bucket: 'week' | 'month'; starts: Date[] } {
|
||||||
|
const start = startOf(from)
|
||||||
|
const end = startOf(to)
|
||||||
|
const days = (end.getTime() - start.getTime()) / DAY_MS + 1
|
||||||
|
const starts: Date[] = []
|
||||||
|
if (days <= 92) {
|
||||||
|
for (let d = weekStart(start); d <= end; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7)) starts.push(d)
|
||||||
|
return { bucket: 'week', starts }
|
||||||
|
}
|
||||||
|
for (let d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) starts.push(d)
|
||||||
|
return { bucket: 'month', starts }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketIndex(starts: Date[], date: Date): number {
|
||||||
|
for (let i = starts.length - 1; i >= 0; i--) if (date >= starts[i]) return i
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeAnalytics(db: Db, user: SessionUser, filters: AnalyticsFilters): Promise<AnalyticsStats> {
|
||||||
|
const from = startOf(filters.from)
|
||||||
|
const to = startOf(addDays(filters.to, 1)) // exclusive
|
||||||
|
const length = to.getTime() - from.getTime()
|
||||||
|
const previousFrom = new Date(from.getTime() - length)
|
||||||
|
const visible = visibleTo(db, user)
|
||||||
|
const scope = and(visible, filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, filters.type ? eq(incidents.type, filters.type) : undefined)
|
||||||
|
const occurredIn = (start: Date, end: Date) => and(scope, gte(incidents.occurredAt, start), lt(incidents.occurredAt, end))
|
||||||
|
|
||||||
|
const columns = { id: incidents.id, type: incidents.type, occurredAt: incidents.occurredAt, createdAt: incidents.createdAt, siteId: incidents.siteId, areaId: incidents.areaId }
|
||||||
|
const current = await db.select(columns).from(incidents).where(occurredIn(from, to))
|
||||||
|
const previous = await db.select(columns).from(incidents).where(occurredIn(previousFrom, from))
|
||||||
|
|
||||||
|
const countGroups = (rows: typeof current) => {
|
||||||
|
const groups = { hazards: 0, injuries: 0, damage: 0 }
|
||||||
|
for (const row of rows) groups[typeGroup[row.type]]++
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
const now = countGroups(current)
|
||||||
|
const before = countGroups(previous)
|
||||||
|
|
||||||
|
// Responsiveness: time from report to the first status change
|
||||||
|
async function triagedShare(rows: typeof current): Promise<number | null> {
|
||||||
|
if (rows.length === 0) return null
|
||||||
|
const firstMoves = await db
|
||||||
|
.select({ id: auditLog.entityId, at: min(auditLog.createdAt) })
|
||||||
|
.from(auditLog)
|
||||||
|
.where(and(eq(auditLog.entityType, 'incident'), eq(auditLog.action, 'status'), inArray(auditLog.entityId, rows.map((r) => r.id))))
|
||||||
|
.groupBy(auditLog.entityId)
|
||||||
|
const moved = new Map(firstMoves.map((m) => [m.id, m.at]))
|
||||||
|
const quick = rows.filter((r) => {
|
||||||
|
const at = moved.get(r.id)
|
||||||
|
return at && at.getTime() - r.createdAt.getTime() <= DAY_MS
|
||||||
|
})
|
||||||
|
return quick.length / rows.length
|
||||||
|
}
|
||||||
|
|
||||||
|
async function averageDaysToClose(start: Date, end: Date) {
|
||||||
|
const closed = await db
|
||||||
|
.select({ createdAt: incidents.createdAt, closedAt: incidents.closedAt })
|
||||||
|
.from(incidents)
|
||||||
|
.where(and(scope, isNotNull(incidents.closedAt), gte(incidents.closedAt, start), lt(incidents.closedAt, end)))
|
||||||
|
if (closed.length === 0) return null
|
||||||
|
return round1(closed.reduce((sum, r) => sum + (r.closedAt!.getTime() - r.createdAt.getTime()), 0) / closed.length / DAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completedActions(start: Date, end: Date) {
|
||||||
|
return db
|
||||||
|
.select({ completedAt: correctiveActions.completedAt, dueDate: correctiveActions.dueDate })
|
||||||
|
.from(correctiveActions)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||||
|
.where(and(scope, isNotNull(correctiveActions.completedAt), isNotNull(correctiveActions.dueDate), gte(correctiveActions.completedAt, start), lt(correctiveActions.completedAt, end)))
|
||||||
|
}
|
||||||
|
const onTime = (row: { completedAt: Date | null; dueDate: string | null }) => localDate(row.completedAt!) <= row.dueDate!
|
||||||
|
const actionsNow = await completedActions(from, to)
|
||||||
|
const actionsBefore = await completedActions(previousFrom, from)
|
||||||
|
|
||||||
|
const today = localDate()
|
||||||
|
const overdueRows = await db
|
||||||
|
.select({ id: correctiveActions.id })
|
||||||
|
.from(correctiveActions)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||||
|
.where(and(scope, inArray(correctiveActions.status, ['open', 'in_progress']), lt(correctiveActions.dueDate, today)))
|
||||||
|
|
||||||
|
// Lagging: OSHA-recordable cases. Counts only, so supervisors may see them.
|
||||||
|
const canSeeRates = hasRole(user.role, 'supervisor')
|
||||||
|
async function recordableRows(start: Date, end: Date) {
|
||||||
|
return db
|
||||||
|
.select({ occurredAt: incidents.occurredAt, outcome: injuredPersons.outcome })
|
||||||
|
.from(injuredPersons)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||||
|
.where(and(eq(incidents.organizationId, user.organizationId), filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, eq(injuredPersons.recordable, true), gte(incidents.occurredAt, start), lt(incidents.occurredAt, end)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpi = (value: number | null, previousValue: number | null): Kpi => ({ value, previous: previousValue })
|
||||||
|
const ratioOf = (groups: typeof now) => (groups.injuries ? Math.round(((groups.hazards / groups.injuries) * 10)) / 10 : null)
|
||||||
|
|
||||||
|
// Trend
|
||||||
|
const { bucket, starts } = buckets(filters.from, filters.to)
|
||||||
|
const trend = starts.map((start) => ({ start: localDate(start), hazards: 0, injuries: 0, damage: 0 }))
|
||||||
|
for (const row of current) {
|
||||||
|
const i = bucketIndex(starts, row.occurredAt)
|
||||||
|
if (i >= 0) trend[i][typeGroup[row.type]]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heatmap: weekday × hour of occurrence
|
||||||
|
const heatmap = Array.from({ length: 7 }, () => Array<number>(24).fill(0))
|
||||||
|
for (const row of current) heatmap[(row.occurredAt.getDay() + 6) % 7][row.occurredAt.getHours()]++
|
||||||
|
|
||||||
|
// Location: sites, or areas within the chosen site
|
||||||
|
let byLocation: AnalyticsStats['byLocation']
|
||||||
|
if (filters.siteId) {
|
||||||
|
const areaRows = await db.select({ id: areas.id, name: areas.name }).from(areas).where(eq(areas.siteId, filters.siteId))
|
||||||
|
const names = new Map(areaRows.map((a) => [a.id, a.name]))
|
||||||
|
const counts = new Map<string | null, number>()
|
||||||
|
for (const row of current) counts.set(row.areaId, (counts.get(row.areaId) ?? 0) + 1)
|
||||||
|
byLocation = [...counts].map(([id, count]) => ({ id, name: id ? (names.get(id) ?? 'Unknown area') : 'Area not specified', count }))
|
||||||
|
} else {
|
||||||
|
const siteRows = await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.organizationId, user.organizationId))
|
||||||
|
const names = new Map(siteRows.map((s) => [s.id, s.name]))
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const row of current) counts.set(row.siteId, (counts.get(row.siteId) ?? 0) + 1)
|
||||||
|
byLocation = [...counts].map(([id, count]) => ({ id, name: names.get(id) ?? 'Unknown site', count }))
|
||||||
|
}
|
||||||
|
byLocation.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
const causeRows = current.length
|
||||||
|
? await db
|
||||||
|
.select({ category: rootCauses.category })
|
||||||
|
.from(rootCauses)
|
||||||
|
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||||
|
.where(inArray(investigations.incidentId, current.map((r) => r.id)))
|
||||||
|
: []
|
||||||
|
const causeCounts = new Map<string, number>()
|
||||||
|
for (const row of causeRows) causeCounts.set(row.category, (causeCounts.get(row.category) ?? 0) + 1)
|
||||||
|
const byRootCause = [...causeCounts].map(([category, count]) => ({ category: category as AnalyticsStats['byRootCause'][number]['category'], count })).sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
// Actions completed per month, on time vs late
|
||||||
|
const actionsByMonth: AnalyticsStats['actionsByMonth'] = []
|
||||||
|
for (let d = new Date(from.getFullYear(), from.getMonth(), 1); d < to; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) actionsByMonth.push({ month: monthKey(d), onTime: 0, late: 0 })
|
||||||
|
for (const row of actionsNow) {
|
||||||
|
const entry = actionsByMonth.find((m) => m.month === monthKey(row.completedAt!))
|
||||||
|
if (entry) entry[onTime(row) ? 'onTime' : 'late']++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rolling 12-month OSHA rates at each of the last 12 month ends
|
||||||
|
let rates: AnalyticsStats['rates'] = null
|
||||||
|
let yearly: AnalyticsStats['yearly'] = null
|
||||||
|
let recordables: Kpi | null = null
|
||||||
|
if (canSeeRates) {
|
||||||
|
recordables = kpi((await recordableRows(from, to)).length, (await recordableRows(previousFrom, from)).length)
|
||||||
|
const endMonth = new Date(startOf(filters.to).getFullYear(), startOf(filters.to).getMonth(), 1)
|
||||||
|
const windowStart = new Date(endMonth.getFullYear() - 5, 0, 1)
|
||||||
|
const cases = await recordableRows(windowStart, new Date(endMonth.getFullYear(), endMonth.getMonth() + 1, 1))
|
||||||
|
const hours = await db
|
||||||
|
.select({ month: hoursWorked.month, hours: hoursWorked.hours })
|
||||||
|
.from(hoursWorked)
|
||||||
|
.innerJoin(sites, eq(sites.id, hoursWorked.siteId))
|
||||||
|
.where(and(eq(sites.organizationId, user.organizationId), filters.siteId ? eq(hoursWorked.siteId, filters.siteId) : undefined, gte(hoursWorked.month, localDate(windowStart))))
|
||||||
|
const hoursByMonth = new Map<string, number>()
|
||||||
|
for (const h of hours) hoursByMonth.set(h.month.slice(0, 7), (hoursByMonth.get(h.month.slice(0, 7)) ?? 0) + Number(h.hours))
|
||||||
|
const casesIn = (start: Date, end: Date) => cases.filter((c) => c.occurredAt >= start && c.occurredAt < end)
|
||||||
|
const hoursIn = (start: Date, end: Date) => {
|
||||||
|
let total = 0
|
||||||
|
for (let d = new Date(start); d < end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) total += hoursByMonth.get(monthKey(d)) ?? 0
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
const isDart = (c: { outcome: string | null }) => c.outcome === 'days_away' || c.outcome === 'restricted'
|
||||||
|
|
||||||
|
rates = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const monthStart = new Date(endMonth.getFullYear(), endMonth.getMonth() - 11 + i, 1)
|
||||||
|
const windowEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1)
|
||||||
|
const windowBegin = new Date(windowEnd.getFullYear() - 1, windowEnd.getMonth(), 1)
|
||||||
|
const inWindow = casesIn(windowBegin, windowEnd)
|
||||||
|
const h = hoursIn(windowBegin, windowEnd)
|
||||||
|
return { month: monthKey(monthStart), trir: incidenceRate(inWindow.length, h), dart: incidenceRate(inWindow.filter(isDart).length, h) }
|
||||||
|
})
|
||||||
|
yearly = Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const year = endMonth.getFullYear() - 4 + i
|
||||||
|
const inYear = casesIn(new Date(year, 0, 1), new Date(year + 1, 0, 1))
|
||||||
|
const h = Math.round(hoursIn(new Date(year, 0, 1), new Date(year + 1, 0, 1)))
|
||||||
|
const dartCases = inYear.filter(isDart).length
|
||||||
|
return { year, recordables: inYear.length, dartCases, hours: h, trir: incidenceRate(inYear.length, h), dart: incidenceRate(dartCases, h) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: filters.from,
|
||||||
|
to: filters.to,
|
||||||
|
previousFrom: localDate(previousFrom),
|
||||||
|
bucket,
|
||||||
|
kpis: {
|
||||||
|
reports: kpi(current.length, previous.length),
|
||||||
|
leadingReports: kpi(now.hazards, before.hazards),
|
||||||
|
injuries: kpi(now.injuries, before.injuries),
|
||||||
|
nearMissRatio: kpi(ratioOf(now), ratioOf(before)),
|
||||||
|
triagedWithin24h: kpi(await triagedShare(current), await triagedShare(previous)),
|
||||||
|
actionsOnTime: kpi(ratio(actionsNow.filter(onTime).length, actionsNow.length), ratio(actionsBefore.filter(onTime).length, actionsBefore.length)),
|
||||||
|
averageDaysToClose: kpi(await averageDaysToClose(from, to), await averageDaysToClose(previousFrom, from)),
|
||||||
|
recordables,
|
||||||
|
overdueActions: overdueRows.length,
|
||||||
|
},
|
||||||
|
trend,
|
||||||
|
byLocation,
|
||||||
|
byRootCause,
|
||||||
|
heatmap,
|
||||||
|
actionsByMonth,
|
||||||
|
rates,
|
||||||
|
yearly,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { count, eq } from 'drizzle-orm'
|
||||||
|
import type { Config } from '../config.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { attachments } from '../db/schema.ts'
|
||||||
|
import type { FileStorage } from '../storage.ts'
|
||||||
|
import { detectFileType } from './file-type.ts'
|
||||||
|
import { fail } from './http.ts'
|
||||||
|
|
||||||
|
export const MAX_ATTACHMENTS_PER_INCIDENT = 20
|
||||||
|
|
||||||
|
/** Validates and stores one uploaded file against an incident. */
|
||||||
|
export async function saveAttachment(
|
||||||
|
{ db, storage, config }: { db: Db; storage: FileStorage; config: Config },
|
||||||
|
incidentId: string,
|
||||||
|
file: unknown,
|
||||||
|
uploadedById: string | null,
|
||||||
|
) {
|
||||||
|
if (!(file instanceof File)) throw fail(400, 'Attach a file in the "file" field')
|
||||||
|
if (file.size > config.maxUploadBytes) throw fail(413, `Files can be at most ${Math.round(config.maxUploadBytes / 1024 / 1024)} MB`)
|
||||||
|
const [{ existing }] = await db.select({ existing: count() }).from(attachments).where(eq(attachments.incidentId, incidentId))
|
||||||
|
if (existing >= MAX_ATTACHMENTS_PER_INCIDENT) throw fail(409, `An incident can have at most ${MAX_ATTACHMENTS_PER_INCIDENT} attachments`)
|
||||||
|
|
||||||
|
const data = new Uint8Array(await file.arrayBuffer())
|
||||||
|
const type = detectFileType(data)
|
||||||
|
if (!type) throw fail(400, 'Only photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs can be attached')
|
||||||
|
|
||||||
|
const storageKey = await storage.save(data, type.extension)
|
||||||
|
const fileName = (file.name || `attachment.${type.extension}`).replace(/[\r\n"\\/]/g, '_').slice(0, 200)
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(attachments)
|
||||||
|
.values({ incidentId, fileName, contentType: type.contentType, sizeBytes: data.byteLength, storageKey, uploadedById })
|
||||||
|
.returning()
|
||||||
|
return row
|
||||||
|
} catch (error) {
|
||||||
|
await storage.remove(storageKey)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { auditLog } from '../db/schema.ts'
|
||||||
|
|
||||||
|
interface AuditEntry {
|
||||||
|
organizationId: string
|
||||||
|
actorId: string | null
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
action: string
|
||||||
|
changes?: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function audit(db: Db, entry: AuditEntry): Promise<void> {
|
||||||
|
await db.insert(auditLog).values({ ...entry, changes: entry.changes ?? null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparable = (value: unknown) => (value instanceof Date ? value.toISOString() : (value ?? null))
|
||||||
|
|
||||||
|
/** `{ field: [before, after] }` for each field in `next` whose value differs from `current`. */
|
||||||
|
export function diff(current: Record<string, unknown>, next: Record<string, unknown>): Record<string, [unknown, unknown]> {
|
||||||
|
const changes: Record<string, [unknown, unknown]> = {}
|
||||||
|
for (const [key, value] of Object.entries(next)) {
|
||||||
|
if (value === undefined) continue
|
||||||
|
const before = comparable(current[key])
|
||||||
|
const after = comparable(value)
|
||||||
|
if (JSON.stringify(before) !== JSON.stringify(after)) changes[key] = [before, after]
|
||||||
|
}
|
||||||
|
return changes
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { toCsv } from './csv.ts'
|
||||||
|
|
||||||
|
describe('toCsv', () => {
|
||||||
|
it('quotes commas, quotes and newlines', () => {
|
||||||
|
expect(toCsv(['a', 'b'], [['x,y', 'say "hi"\nok']])).toBe('\uFEFFa,b\r\n"x,y","say ""hi""\nok"\r\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('neutralises formula prefixes and blanks nulls', () => {
|
||||||
|
expect(toCsv(['a'], [['=1+1'], ['-2'], [null], [3]])).toBe("\uFEFFa\r\n'=1+1\r\n'-2\r\n\r\n3\r\n")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
type Cell = string | number | null | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 4180 CSV. By default starts with a BOM so Excel opens UTF-8 correctly (`bom: false` for machine
|
||||||
|
* uploads such as OSHA's ITA). Cells a spreadsheet would treat as a formula are prefixed with an
|
||||||
|
* apostrophe (CSV injection).
|
||||||
|
*/
|
||||||
|
export function toCsv(header: string[], rows: Cell[][], { bom = true }: { bom?: boolean } = {}): string {
|
||||||
|
const cell = (value: Cell) => {
|
||||||
|
let text = value === null || value === undefined ? '' : String(value)
|
||||||
|
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`
|
||||||
|
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
||||||
|
}
|
||||||
|
return (bom ? '\uFEFF' : '') + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n'
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** ISO string for a Date, keeping nulls. */
|
||||||
|
export const iso = <T extends Date | null>(date: T): T extends Date ? string : string | null => (date ? date.toISOString() : null) as never
|
||||||
|
|
||||||
|
/** Calendar date in the server's time zone (set TZ to the plant's zone), e.g. `2026-10-06`. */
|
||||||
|
export function localDate(date = new Date()): string {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(isoDate: string, days: number): string {
|
||||||
|
const date = new Date(`${isoDate}T12:00:00`)
|
||||||
|
date.setDate(date.getDate() + days)
|
||||||
|
return localDate(date)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { detectFileType } from './file-type.ts'
|
||||||
|
|
||||||
|
const bytes = (...values: (number | string)[]) => new Uint8Array(values.flatMap((v) => (typeof v === 'string' ? [...v].map((ch) => ch.charCodeAt(0)) : [v])))
|
||||||
|
|
||||||
|
describe('detectFileType', () => {
|
||||||
|
it('recognises supported formats', () => {
|
||||||
|
expect(detectFileType(bytes(0xff, 0xd8, 0xff, 0xe0))?.contentType).toBe('image/jpeg')
|
||||||
|
expect(detectFileType(bytes('RIFF', 0, 0, 0, 0, 'WEBP'))?.contentType).toBe('image/webp')
|
||||||
|
expect(detectFileType(bytes(0, 0, 0, 24, 'ftypheic'))?.contentType).toBe('image/heic')
|
||||||
|
expect(detectFileType(bytes('%PDF-1.7'))?.contentType).toBe('application/pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects everything else', () => {
|
||||||
|
expect(detectFileType(bytes('<svg xmlns="http://www.w3.org/2000/svg">'))).toBeNull()
|
||||||
|
expect(detectFileType(bytes('PK', 3, 4))).toBeNull()
|
||||||
|
expect(detectFileType(new Uint8Array())).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export interface DetectedType {
|
||||||
|
contentType: string
|
||||||
|
extension: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const startsWith = (bytes: Uint8Array, signature: number[], offset = 0) => signature.every((byte, i) => bytes[offset + i] === byte)
|
||||||
|
const ascii = (bytes: Uint8Array, from: number, to: number) => String.fromCharCode(...bytes.subarray(from, to))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifies photos and PDFs from their first bytes, ignoring the client's claimed type and file name.
|
||||||
|
* Returns null for anything else, so only these formats are ever stored and served.
|
||||||
|
*/
|
||||||
|
export function detectFileType(bytes: Uint8Array): DetectedType | null {
|
||||||
|
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return { contentType: 'image/jpeg', extension: 'jpg' }
|
||||||
|
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { contentType: 'image/png', extension: 'png' }
|
||||||
|
if (ascii(bytes, 0, 6) === 'GIF87a' || ascii(bytes, 0, 6) === 'GIF89a') return { contentType: 'image/gif', extension: 'gif' }
|
||||||
|
if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'WEBP') return { contentType: 'image/webp', extension: 'webp' }
|
||||||
|
if (ascii(bytes, 4, 8) === 'ftyp' && ['heic', 'heix', 'mif1', 'msf1'].includes(ascii(bytes, 8, 12))) return { contentType: 'image/heic', extension: 'heic' }
|
||||||
|
if (ascii(bytes, 0, 5) === '%PDF-') return { contentType: 'application/pdf', extension: 'pdf' }
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { zValidator } from '@hono/zod-validator'
|
||||||
|
import type { Context, ValidationTargets } from 'hono'
|
||||||
|
import { HTTPException } from 'hono/http-exception'
|
||||||
|
import type { ZodType } from 'zod'
|
||||||
|
import type { SessionUser } from '../auth/sessions.ts'
|
||||||
|
import type { Config } from '../config.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import type { Notifier } from '../notifications.ts'
|
||||||
|
import type { FileStorage } from '../storage.ts'
|
||||||
|
|
||||||
|
export interface AppEnv {
|
||||||
|
Variables: {
|
||||||
|
db: Db
|
||||||
|
config: Config
|
||||||
|
user: SessionUser | null
|
||||||
|
notifier: Notifier
|
||||||
|
storage: FileStorage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates part of the request, answering 400 with field-level issues on failure. */
|
||||||
|
export function validate<Target extends keyof ValidationTargets, Schema extends ZodType>(target: Target, schema: Schema) {
|
||||||
|
return zValidator(target, schema, (result, c) => {
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({ error: 'Invalid request', issues: result.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, 400)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fail = (status: 400 | 401 | 403 | 404 | 409 | 413 | 429, message: string) => new HTTPException(status, { message })
|
||||||
|
|
||||||
|
/** The signed-in user; use after `requireAuth`. */
|
||||||
|
export function currentUser(c: Context<AppEnv>): SessionUser {
|
||||||
|
const user = c.get('user')
|
||||||
|
if (!user) throw fail(401, 'Sign in required')
|
||||||
|
return user
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { and, asc, count, eq, exists, or, type SQL } from 'drizzle-orm'
|
||||||
|
import { alias } from 'drizzle-orm/pg-core'
|
||||||
|
import type { Attachment, CorrectiveAction, Incident, IncidentDetail, Investigation, RootCause } from '../../shared/api-types.ts'
|
||||||
|
import { hasRole } from '../../shared/enums.ts'
|
||||||
|
import { incidentReference } from '../../shared/format.ts'
|
||||||
|
import { availableTransitions } from '../../shared/workflow.ts'
|
||||||
|
import type { SessionUser } from '../auth/sessions.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { areas, attachments, correctiveActions, incidents, injuredPersons, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts'
|
||||||
|
import { audit } from './audit.ts'
|
||||||
|
import { iso, localDate } from './dates.ts'
|
||||||
|
import { fail } from './http.ts'
|
||||||
|
|
||||||
|
const reporters = alias(users, 'reporter')
|
||||||
|
|
||||||
|
export function selectIncidents(db: Db) {
|
||||||
|
return db
|
||||||
|
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name, kioskName: kiosks.name })
|
||||||
|
.from(incidents)
|
||||||
|
.innerJoin(sites, eq(sites.id, incidents.siteId))
|
||||||
|
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||||
|
.leftJoin(reporters, eq(reporters.id, incidents.reporterId))
|
||||||
|
.leftJoin(kiosks, eq(kiosks.id, incidents.kioskId))
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number]
|
||||||
|
|
||||||
|
export function toIncident({ incident, siteName, areaName, reporterName, kioskName }: IncidentRow): Incident {
|
||||||
|
return {
|
||||||
|
id: incident.id,
|
||||||
|
number: incident.number,
|
||||||
|
reference: incidentReference(incident.number),
|
||||||
|
title: incident.title,
|
||||||
|
type: incident.type,
|
||||||
|
severity: incident.severity,
|
||||||
|
status: incident.status,
|
||||||
|
occurredAt: iso(incident.occurredAt),
|
||||||
|
reportedAt: iso(incident.createdAt),
|
||||||
|
updatedAt: iso(incident.updatedAt),
|
||||||
|
closedAt: iso(incident.closedAt),
|
||||||
|
site: { id: incident.siteId, name: siteName },
|
||||||
|
area: incident.areaId ? { id: incident.areaId, name: areaName ?? '' } : null,
|
||||||
|
locationDetail: incident.locationDetail,
|
||||||
|
description: incident.description,
|
||||||
|
immediateAction: incident.immediateAction,
|
||||||
|
anonymous: incident.anonymous,
|
||||||
|
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName ?? '' } : null,
|
||||||
|
reporterName: incident.reporterName,
|
||||||
|
channel: incident.channel,
|
||||||
|
kiosk: incident.kioskId ? { id: incident.kioskId, name: kioskName ?? '' } : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supervisors and above see every incident in their organization. Reporters see incidents they
|
||||||
|
* reported, plus any where they are assigned a corrective action.
|
||||||
|
*/
|
||||||
|
export function visibleTo(db: Db, user: SessionUser): SQL | undefined {
|
||||||
|
const inOrg = eq(incidents.organizationId, user.organizationId)
|
||||||
|
if (hasRole(user.role, 'supervisor')) return inOrg
|
||||||
|
const assigned = db
|
||||||
|
.select({ id: correctiveActions.id })
|
||||||
|
.from(correctiveActions)
|
||||||
|
.where(and(eq(correctiveActions.incidentId, incidents.id), eq(correctiveActions.assigneeId, user.id)))
|
||||||
|
return and(inOrg, or(eq(incidents.reporterId, user.id), exists(assigned)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findIncident(db: Db, user: SessionUser, number: number) {
|
||||||
|
const [row] = await selectIncidents(db).where(and(visibleTo(db, user), eq(incidents.number, number)))
|
||||||
|
if (!row) throw fail(404, 'Incident not found')
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignees = alias(users, 'assignee')
|
||||||
|
const verifiers = alias(users, 'verifier')
|
||||||
|
|
||||||
|
export function selectActions(db: Db) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
action: correctiveActions,
|
||||||
|
incidentNumber: incidents.number,
|
||||||
|
incidentTitle: incidents.title,
|
||||||
|
assigneeName: assignees.name,
|
||||||
|
verifierName: verifiers.name,
|
||||||
|
})
|
||||||
|
.from(correctiveActions)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||||
|
.leftJoin(assignees, eq(assignees.id, correctiveActions.assigneeId))
|
||||||
|
.leftJoin(verifiers, eq(verifiers.id, correctiveActions.verifiedById))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionRow = Awaited<ReturnType<ReturnType<typeof selectActions>['execute']>>[number]
|
||||||
|
|
||||||
|
export const isOpenAction = (status: string) => status === 'open' || status === 'in_progress'
|
||||||
|
|
||||||
|
export function toAction({ action, incidentNumber, incidentTitle, assigneeName, verifierName }: ActionRow, today = localDate()): CorrectiveAction {
|
||||||
|
return {
|
||||||
|
id: action.id,
|
||||||
|
incident: { number: incidentNumber, reference: incidentReference(incidentNumber), title: incidentTitle },
|
||||||
|
rootCauseId: action.rootCauseId,
|
||||||
|
kind: action.kind,
|
||||||
|
title: action.title,
|
||||||
|
description: action.description,
|
||||||
|
assignee: action.assigneeId ? { id: action.assigneeId, name: assigneeName ?? '' } : null,
|
||||||
|
dueDate: action.dueDate,
|
||||||
|
status: action.status,
|
||||||
|
overdue: isOpenAction(action.status) && !!action.dueDate && action.dueDate < today,
|
||||||
|
completedAt: iso(action.completedAt),
|
||||||
|
verifiedBy: action.verifiedById ? { id: action.verifiedById, name: verifierName ?? '' } : null,
|
||||||
|
verifiedAt: iso(action.verifiedAt),
|
||||||
|
createdAt: iso(action.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAttachment(row: typeof attachments.$inferSelect, uploaderName: string | null): Attachment {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
fileName: row.fileName,
|
||||||
|
contentType: row.contentType,
|
||||||
|
sizeBytes: row.sizeBytes,
|
||||||
|
uploadedBy: row.uploadedById ? { id: row.uploadedById, name: uploaderName ?? '' } : null,
|
||||||
|
createdAt: iso(row.createdAt),
|
||||||
|
url: `/api/attachments/${row.id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDetail(db: Db, user: SessionUser, number: number): Promise<IncidentDetail> {
|
||||||
|
const row = await findIncident(db, user, number)
|
||||||
|
const incidentId = row.incident.id
|
||||||
|
const leads = alias(users, 'lead')
|
||||||
|
|
||||||
|
const [investigationRow] = await db
|
||||||
|
.select({ investigation: investigations, leadName: leads.name })
|
||||||
|
.from(investigations)
|
||||||
|
.leftJoin(leads, eq(leads.id, investigations.leadId))
|
||||||
|
.where(eq(investigations.incidentId, incidentId))
|
||||||
|
|
||||||
|
const causes = investigationRow
|
||||||
|
? await db.select().from(rootCauses).where(eq(rootCauses.investigationId, investigationRow.investigation.id)).orderBy(asc(rootCauses.createdAt))
|
||||||
|
: []
|
||||||
|
const actionRows = await selectActions(db).where(eq(correctiveActions.incidentId, incidentId)).orderBy(asc(correctiveActions.createdAt))
|
||||||
|
const uploaders = alias(users, 'uploader')
|
||||||
|
const files = await db
|
||||||
|
.select({ file: attachments, uploaderName: uploaders.name })
|
||||||
|
.from(attachments)
|
||||||
|
.leftJoin(uploaders, eq(uploaders.id, attachments.uploadedById))
|
||||||
|
.where(eq(attachments.incidentId, incidentId))
|
||||||
|
.orderBy(asc(attachments.createdAt))
|
||||||
|
|
||||||
|
const investigation: Investigation | null = investigationRow
|
||||||
|
? {
|
||||||
|
id: investigationRow.investigation.id,
|
||||||
|
lead: investigationRow.investigation.leadId ? { id: investigationRow.investigation.leadId, name: investigationRow.leadName ?? '' } : null,
|
||||||
|
method: investigationRow.investigation.method,
|
||||||
|
summary: investigationRow.investigation.summary,
|
||||||
|
startedAt: iso(investigationRow.investigation.startedAt),
|
||||||
|
completedAt: iso(investigationRow.investigation.completedAt),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
const actions = actionRows.map((action) => toAction(action))
|
||||||
|
const manage = hasRole(user.role, 'supervisor')
|
||||||
|
const [{ injured }] = await db.select({ injured: count() }).from(injuredPersons).where(eq(injuredPersons.incidentId, incidentId))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...toIncident(row),
|
||||||
|
investigation,
|
||||||
|
rootCauses: causes.map((cause): RootCause => ({ id: cause.id, category: cause.category, description: cause.description, whys: cause.whys })),
|
||||||
|
actions,
|
||||||
|
attachments: files.map(({ file, uploaderName }) => toAttachment(file, uploaderName)),
|
||||||
|
transitions: availableTransitions(row.incident.status, user.role, {
|
||||||
|
severity: row.incident.severity,
|
||||||
|
rootCauseCount: causes.length,
|
||||||
|
actionStatuses: actions.map((action) => action.status),
|
||||||
|
}),
|
||||||
|
injuredCount: injured,
|
||||||
|
can: { edit: manage, manageInvestigation: manage, manageActions: manage, manageOsha: hasRole(user.role, 'safety_manager') },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records an event on an incident's timeline. */
|
||||||
|
export function auditIncident(db: Db, user: SessionUser | null, organizationId: string, incidentId: string, action: string, changes?: Record<string, unknown>) {
|
||||||
|
return audit(db, { organizationId, actorId: user?.id ?? null, entityType: 'incident', entityId: incidentId, action, changes })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws 400 unless the user is active in the organization. */
|
||||||
|
export async function assertActiveUser(db: Db, organizationId: string, userId: string) {
|
||||||
|
const [user] = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(and(eq(users.id, userId), eq(users.organizationId, organizationId), eq(users.active, true)))
|
||||||
|
if (!user) throw fail(400, 'Unknown or inactive user')
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { and, asc, eq, gte, isNull, lt } from 'drizzle-orm'
|
||||||
|
import type { Establishment, OshaCase, OshaLogRow, OshaYear } from '../../shared/api-types.ts'
|
||||||
|
import { oshaCaseTypes } from '../../shared/enums.ts'
|
||||||
|
import { incidentReference } from '../../shared/format.ts'
|
||||||
|
import { capDays, incidenceRate, severeInjuryReport, summaryChecks, type SummaryTotals } from '../../shared/osha.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { areas, hoursWorked, incidents, injuredPersons, organizations, sites } from '../db/schema.ts'
|
||||||
|
import { iso } from './dates.ts'
|
||||||
|
import { fail } from './http.ts'
|
||||||
|
|
||||||
|
type Person = typeof injuredPersons.$inferSelect
|
||||||
|
type IncidentInfo = { id: string; number: number; title: string; occurredAt: Date; createdAt: Date }
|
||||||
|
|
||||||
|
/** Jan 1 to Jan 1 in the server's time zone (set TZ to the plant's zone). */
|
||||||
|
export function yearRange(year: number): [Date, Date] {
|
||||||
|
return [new Date(year, 0, 1), new Date(year + 1, 0, 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields the ITA case upload and Form 301 require that are still empty. */
|
||||||
|
export function missingFields(person: Person): string[] {
|
||||||
|
if (!person.recordable) return []
|
||||||
|
const required: [keyof Person, string][] = [
|
||||||
|
['jobTitle', 'job title'],
|
||||||
|
['dateOfBirth', 'date of birth'],
|
||||||
|
['dateOfHire', 'date hired'],
|
||||||
|
['activityBefore', 'what they were doing'],
|
||||||
|
['whatHappened', 'what happened'],
|
||||||
|
['natureOfInjury', 'the injury or illness'],
|
||||||
|
['objectSubstance', 'object or substance'],
|
||||||
|
]
|
||||||
|
const missing = required.filter(([key]) => !person[key]).map(([, label]) => label)
|
||||||
|
if (person.died && !person.dateOfDeath) missing.push('date of death')
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toOshaCase(person: Person, incident: IncidentInfo): OshaCase {
|
||||||
|
const severe = severeInjuryReport(person)
|
||||||
|
return {
|
||||||
|
id: person.id,
|
||||||
|
incident: { id: incident.id, number: incident.number, reference: incidentReference(incident.number), title: incident.title, occurredAt: iso(incident.occurredAt) },
|
||||||
|
caseNumber: person.caseNumber ?? incidentReference(incident.number),
|
||||||
|
name: person.name,
|
||||||
|
jobTitle: person.jobTitle,
|
||||||
|
employmentType: person.employmentType,
|
||||||
|
bodyPart: person.bodyPart,
|
||||||
|
natureOfInjury: person.natureOfInjury,
|
||||||
|
caseType: person.caseType,
|
||||||
|
workRelated: person.workRelated,
|
||||||
|
newCase: person.newCase,
|
||||||
|
died: person.died,
|
||||||
|
dateOfDeath: person.dateOfDeath,
|
||||||
|
daysAwayFromWork: person.daysAwayFromWork,
|
||||||
|
daysAway: person.daysAway,
|
||||||
|
restrictedOrTransfer: person.restrictedOrTransfer,
|
||||||
|
daysRestricted: person.daysRestricted,
|
||||||
|
medicalTreatment: person.medicalTreatment,
|
||||||
|
lossOfConsciousness: person.lossOfConsciousness,
|
||||||
|
significantDiagnosis: person.significantDiagnosis,
|
||||||
|
specialCase: person.specialCase,
|
||||||
|
privacyCase: person.privacyCase,
|
||||||
|
recordable: person.recordable,
|
||||||
|
outcome: person.outcome,
|
||||||
|
inpatientHospitalization: person.inpatientHospitalization,
|
||||||
|
amputation: person.amputation,
|
||||||
|
lossOfEye: person.lossOfEye,
|
||||||
|
reportedToOshaAt: iso(person.reportedToOshaAt),
|
||||||
|
// The clock starts when the employer learns of it; the report date is the best record of that.
|
||||||
|
severeReport: severe && { ...severe, dueBy: new Date(incident.createdAt.getTime() + severe.withinHours * 3_600_000).toISOString() },
|
||||||
|
employeeAddress: person.employeeAddress,
|
||||||
|
dateOfBirth: person.dateOfBirth,
|
||||||
|
dateOfHire: person.dateOfHire,
|
||||||
|
sex: person.sex === 'M' || person.sex === 'F' ? person.sex : null,
|
||||||
|
physicianName: person.physicianName,
|
||||||
|
treatmentFacility: person.treatmentFacility,
|
||||||
|
treatedInEmergencyRoom: person.treatedInEmergencyRoom,
|
||||||
|
timeStartedWork: person.timeStartedWork,
|
||||||
|
activityBefore: person.activityBefore,
|
||||||
|
whatHappened: person.whatHappened,
|
||||||
|
objectSubstance: person.objectSubstance,
|
||||||
|
missingFields: missingFields(person),
|
||||||
|
updatedAt: iso(person.updatedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Form 300 column F: injury, body part, and what caused it. */
|
||||||
|
export function describeCase(person: Pick<Person, 'natureOfInjury' | 'bodyPart' | 'objectSubstance'>): string {
|
||||||
|
const injury = [person.natureOfInjury, person.bodyPart && `(${person.bodyPart})`].filter(Boolean).join(' ')
|
||||||
|
return [injury, person.objectSubstance].filter(Boolean).join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const establishmentOf = (site: typeof sites.$inferSelect): Establishment => ({
|
||||||
|
streetAddress: site.streetAddress,
|
||||||
|
city: site.city,
|
||||||
|
state: site.state,
|
||||||
|
zip: site.zip,
|
||||||
|
naicsCode: site.naicsCode,
|
||||||
|
industryDescription: site.industryDescription,
|
||||||
|
ein: site.ein,
|
||||||
|
establishmentType: site.establishmentType,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function missingEstablishmentFields(site: typeof sites.$inferSelect): string[] {
|
||||||
|
const required: [keyof typeof site, string][] = [
|
||||||
|
['streetAddress', 'street address'],
|
||||||
|
['city', 'city'],
|
||||||
|
['state', 'state'],
|
||||||
|
['zip', 'ZIP code'],
|
||||||
|
['naicsCode', 'NAICS code'],
|
||||||
|
['ein', 'EIN'],
|
||||||
|
]
|
||||||
|
return required.filter(([key]) => !site[key]).map(([, label]) => label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything for one establishment's year: the 300 Log, 300A totals, hours, rates and ITA checks. */
|
||||||
|
export async function buildOshaYear(db: Db, organizationId: string, siteId: string, year: number): Promise<OshaYear> {
|
||||||
|
const [site] = await db.select().from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId)))
|
||||||
|
if (!site) throw fail(404, 'Site not found')
|
||||||
|
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, organizationId))
|
||||||
|
const [from, to] = yearRange(year)
|
||||||
|
|
||||||
|
const caseRows = await db
|
||||||
|
.select({ person: injuredPersons, incident: incidents, areaName: areas.name })
|
||||||
|
.from(injuredPersons)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||||
|
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||||
|
.where(and(eq(incidents.siteId, siteId), eq(injuredPersons.recordable, true), gte(incidents.occurredAt, from), lt(incidents.occurredAt, to)))
|
||||||
|
.orderBy(asc(incidents.occurredAt), asc(injuredPersons.caseNumber))
|
||||||
|
|
||||||
|
const rows: OshaLogRow[] = caseRows.map(({ person, incident, areaName }) => {
|
||||||
|
const days = capDays(person.daysAwayFromWork ? person.daysAway : 0, person.restrictedOrTransfer ? person.daysRestricted : 0)
|
||||||
|
return {
|
||||||
|
caseId: person.id,
|
||||||
|
caseNumber: person.caseNumber ?? incidentReference(incident.number),
|
||||||
|
incidentNumber: incident.number,
|
||||||
|
employeeName: person.privacyCase ? 'Privacy case' : person.name,
|
||||||
|
jobTitle: person.jobTitle ?? '',
|
||||||
|
dateOfInjury: iso(incident.occurredAt),
|
||||||
|
location: [areaName, incident.locationDetail].filter(Boolean).join(', ') || site.name,
|
||||||
|
description: describeCase(person),
|
||||||
|
outcome: person.outcome ?? 'other',
|
||||||
|
daysAway: days.daysAway,
|
||||||
|
daysRestricted: days.daysRestricted,
|
||||||
|
caseType: person.caseType,
|
||||||
|
missingFields: missingFields(person),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const totals: SummaryTotals = {
|
||||||
|
deaths: rows.filter((r) => r.outcome === 'death').length,
|
||||||
|
daysAwayCases: rows.filter((r) => r.outcome === 'days_away').length,
|
||||||
|
restrictedCases: rows.filter((r) => r.outcome === 'restricted').length,
|
||||||
|
otherCases: rows.filter((r) => r.outcome === 'other').length,
|
||||||
|
daysAway: rows.reduce((sum, r) => sum + r.daysAway, 0),
|
||||||
|
daysRestricted: rows.reduce((sum, r) => sum + r.daysRestricted, 0),
|
||||||
|
byType: Object.fromEntries(oshaCaseTypes.map((type) => [type, rows.filter((r) => r.caseType === type).length])) as SummaryTotals['byType'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthRows = await db
|
||||||
|
.select()
|
||||||
|
.from(hoursWorked)
|
||||||
|
.where(and(eq(hoursWorked.siteId, siteId), gte(hoursWorked.month, `${year}-01-01`), lt(hoursWorked.month, `${year + 1}-01-01`)))
|
||||||
|
const months = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const row = monthRows.find((m) => Number(m.month.slice(5, 7)) === i + 1)
|
||||||
|
return { month: i + 1, hours: row ? Number(row.hours) : null, averageEmployees: row?.averageEmployees ?? null }
|
||||||
|
})
|
||||||
|
const totalHoursWorked = Math.round(months.reduce((sum, m) => sum + (m.hours ?? 0), 0))
|
||||||
|
const headcounts = months.map((m) => m.averageEmployees).filter((n): n is number => n !== null)
|
||||||
|
// 1904.32 instructions: total of employees each pay period ÷ number of pay periods; here, months.
|
||||||
|
const annualAverageEmployees = headcounts.length ? Math.round(headcounts.reduce((a, b) => a + b, 0) / headcounts.length) : 0
|
||||||
|
const maxEmployees = headcounts.length ? Math.max(...headcounts) : 0
|
||||||
|
const recordable = rows.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
site: { id: site.id, name: site.name, ...establishmentOf(site) },
|
||||||
|
companyName: organization.name,
|
||||||
|
year,
|
||||||
|
rows,
|
||||||
|
totals,
|
||||||
|
months,
|
||||||
|
totalHoursWorked,
|
||||||
|
annualAverageEmployees,
|
||||||
|
maxEmployees,
|
||||||
|
rates: {
|
||||||
|
trir: incidenceRate(recordable, totalHoursWorked),
|
||||||
|
dart: incidenceRate(totals.daysAwayCases + totals.restrictedCases, totalHoursWorked),
|
||||||
|
ltir: incidenceRate(totals.daysAwayCases, totalHoursWorked),
|
||||||
|
},
|
||||||
|
checks: summaryChecks(totals, annualAverageEmployees, totalHoursWorked),
|
||||||
|
missingEstablishmentFields: missingEstablishmentFields(site),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active sites for an organization (establishments that may need a log). */
|
||||||
|
export function activeSites(db: Db, organizationId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(and(eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
|
||||||
|
.orderBy(asc(sites.name))
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { and, eq, isNull, sql } from 'drizzle-orm'
|
||||||
|
import type { IncidentType, Severity } from '../../shared/enums.ts'
|
||||||
|
import type { SessionUser } from '../auth/sessions.ts'
|
||||||
|
import { isUniqueViolation, type Db } from '../db/client.ts'
|
||||||
|
import { areas, incidentCounters, incidents, sites } from '../db/schema.ts'
|
||||||
|
import type { Notifier } from '../notifications.ts'
|
||||||
|
import { fail } from './http.ts'
|
||||||
|
import { auditIncident } from './incidents.ts'
|
||||||
|
|
||||||
|
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
|
||||||
|
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
export function parseOccurredAt(value: string): Date {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future')
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
|
||||||
|
const [site] = await db
|
||||||
|
.select({ id: sites.id })
|
||||||
|
.from(sites)
|
||||||
|
.where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
|
||||||
|
if (!site) throw fail(400, 'Unknown site')
|
||||||
|
if (areaId) {
|
||||||
|
const [area] = await db
|
||||||
|
.select({ id: areas.id })
|
||||||
|
.from(areas)
|
||||||
|
.where(and(eq(areas.id, areaId), eq(areas.siteId, siteId)))
|
||||||
|
if (!area) throw fail(400, 'Area must belong to the selected site')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewReport {
|
||||||
|
organizationId: string
|
||||||
|
title: string
|
||||||
|
type: IncidentType
|
||||||
|
severity: Severity
|
||||||
|
occurredAt: string
|
||||||
|
siteId: string
|
||||||
|
areaId?: string | null
|
||||||
|
locationDetail?: string | null
|
||||||
|
description?: string | null
|
||||||
|
immediateAction?: string | null
|
||||||
|
clientId?: string
|
||||||
|
/** Signed-in reporter, or null for anonymous and kiosk reports. */
|
||||||
|
reporter: SessionUser | null
|
||||||
|
reporterName?: string | null
|
||||||
|
anonymous: boolean
|
||||||
|
kioskId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files a report with the next incident number. If `clientId` was already used in the organization
|
||||||
|
* (a retried submission), returns the existing incident instead of creating a duplicate.
|
||||||
|
*/
|
||||||
|
export async function fileReport(db: Db, notifier: Notifier, report: NewReport): Promise<{ id: string; number: number; created: boolean }> {
|
||||||
|
const { organizationId, reporter, anonymous, kioskId, occurredAt, clientId, ...fields } = report
|
||||||
|
await assertLocation(db, organizationId, fields.siteId, fields.areaId)
|
||||||
|
const occurred = parseOccurredAt(occurredAt)
|
||||||
|
|
||||||
|
const existing = async () => {
|
||||||
|
if (!clientId) return undefined
|
||||||
|
const [row] = await db
|
||||||
|
.select({ id: incidents.id, number: incidents.number })
|
||||||
|
.from(incidents)
|
||||||
|
.where(and(eq(incidents.organizationId, organizationId), eq(incidents.clientId, clientId)))
|
||||||
|
return row && { ...row, created: false }
|
||||||
|
}
|
||||||
|
const previous = await existing()
|
||||||
|
if (previous) return previous
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await db.transaction(async (tx) => {
|
||||||
|
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
|
||||||
|
const [{ lastNumber }] = await tx
|
||||||
|
.insert(incidentCounters)
|
||||||
|
.values({ organizationId, lastNumber: 1 })
|
||||||
|
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
|
||||||
|
.returning({ lastNumber: incidentCounters.lastNumber })
|
||||||
|
const [incident] = await tx
|
||||||
|
.insert(incidents)
|
||||||
|
.values({
|
||||||
|
...fields,
|
||||||
|
occurredAt: occurred,
|
||||||
|
organizationId,
|
||||||
|
number: lastNumber,
|
||||||
|
clientId,
|
||||||
|
anonymous,
|
||||||
|
reporterId: anonymous ? null : (reporter?.id ?? null),
|
||||||
|
reporterName: anonymous ? null : (fields.reporterName ?? null),
|
||||||
|
channel: kioskId ? 'kiosk' : 'web',
|
||||||
|
kioskId,
|
||||||
|
})
|
||||||
|
.returning({ id: incidents.id, number: incidents.number })
|
||||||
|
await auditIncident(tx, anonymous ? null : reporter, organizationId, incident.id, 'create', {
|
||||||
|
number: lastNumber,
|
||||||
|
title: fields.title,
|
||||||
|
type: fields.type,
|
||||||
|
severity: fields.severity,
|
||||||
|
...(kioskId ? { kioskId } : {}),
|
||||||
|
})
|
||||||
|
return incident
|
||||||
|
})
|
||||||
|
notifier.incidentReported(created.id)
|
||||||
|
return { ...created, created: true }
|
||||||
|
} catch (error) {
|
||||||
|
// Two copies of the same submission raced; the other one won.
|
||||||
|
if (clientId && isUniqueViolation(error)) {
|
||||||
|
const winner = await existing()
|
||||||
|
if (winner) return winner
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import type { Config } from './config.ts'
|
||||||
|
|
||||||
|
export interface Mail {
|
||||||
|
to: string
|
||||||
|
subject: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mailer {
|
||||||
|
send(mail: Mail): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMailer(config: Config): Mailer {
|
||||||
|
if (config.smtpUrl) {
|
||||||
|
const transport = nodemailer.createTransport(config.smtpUrl)
|
||||||
|
return { send: async (mail) => void (await transport.sendMail({ from: config.mailFrom, ...mail })) }
|
||||||
|
}
|
||||||
|
if (config.production) {
|
||||||
|
console.warn('[mail] SMTP_URL is not set: emails (sign-in links, password resets, notifications) will not be sent.')
|
||||||
|
return { send: async (mail) => console.warn(`[mail] Not sent (no SMTP): "${mail.subject}" to ${mail.to}`) }
|
||||||
|
}
|
||||||
|
// Development: print the email so links can be clicked from the terminal.
|
||||||
|
return { send: async (mail) => console.log(`\n[mail] To: ${mail.to}\n[mail] Subject: ${mail.subject}\n\n${mail.text}\n`) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collects emails instead of sending them (tests). */
|
||||||
|
export class MemoryMailer implements Mailer {
|
||||||
|
sent: Mail[] = []
|
||||||
|
|
||||||
|
async send(mail: Mail) {
|
||||||
|
this.sent.push(mail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most recent email to `to`. */
|
||||||
|
last(to: string): Mail | undefined {
|
||||||
|
return this.sent.findLast((mail) => mail.to === to)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { and, eq, inArray, isNotNull, isNull, lt, lte, ne, or } from 'drizzle-orm'
|
||||||
|
import { incidentReference } from '../shared/format.ts'
|
||||||
|
import type { Config } from './config.ts'
|
||||||
|
import type { Db } from './db/client.ts'
|
||||||
|
import { OSHA_REPORTING_CONTACT, severeInjuryReport } from '../shared/osha.ts'
|
||||||
|
import { correctiveActions, incidents, injuredPersons, users } from './db/schema.ts'
|
||||||
|
import { addDays, localDate } from './lib/dates.ts'
|
||||||
|
import type { Mailer } from './mail.ts'
|
||||||
|
|
||||||
|
const severityLabel = { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' } as const
|
||||||
|
|
||||||
|
/** Sends emails in the background so slow mail servers never delay or fail a request. */
|
||||||
|
export class Notifier {
|
||||||
|
private readonly pending = new Set<Promise<void>>()
|
||||||
|
private readonly db: Db
|
||||||
|
private readonly mailer: Mailer
|
||||||
|
private readonly config: Config
|
||||||
|
|
||||||
|
constructor(db: Db, mailer: Mailer, config: Config) {
|
||||||
|
this.db = db
|
||||||
|
this.mailer = mailer
|
||||||
|
this.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits for queued emails (tests, shutdown). */
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
await Promise.all([...this.pending])
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatch(task: () => Promise<void>) {
|
||||||
|
const run = task()
|
||||||
|
.catch((error) => console.error('[notify] failed:', error))
|
||||||
|
.finally(() => this.pending.delete(run))
|
||||||
|
this.pending.add(run)
|
||||||
|
}
|
||||||
|
|
||||||
|
private link(path: string) {
|
||||||
|
return new URL(path, this.config.publicUrl).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
magicLink(email: string, token: string) {
|
||||||
|
this.dispatch(() =>
|
||||||
|
this.mailer.send({
|
||||||
|
to: email,
|
||||||
|
subject: 'Your sign-in link',
|
||||||
|
text: `Use this link to sign in to Open SafetyLog EHS. It works once and expires in 15 minutes.\n\n${this.link(`/auth/magic?token=${token}`)}\n\nIf you didn't ask for it, you can ignore this email.`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordReset(email: string, token: string, invite: { organization: string; invitedBy: string } | null) {
|
||||||
|
const link = this.link(`/auth/reset?token=${token}`)
|
||||||
|
this.dispatch(() =>
|
||||||
|
this.mailer.send(
|
||||||
|
invite
|
||||||
|
? {
|
||||||
|
to: email,
|
||||||
|
subject: `You've been invited to ${invite.organization} on Open SafetyLog EHS`,
|
||||||
|
text: `${invite.invitedBy} added you to ${invite.organization}'s safety workspace.\n\nSet your password to get started (link valid for 7 days):\n\n${link}`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
to: email,
|
||||||
|
subject: 'Reset your password',
|
||||||
|
text: `Use this link to choose a new password. It works once and expires in 1 hour.\n\n${link}\n\nIf you didn't ask for it, you can ignore this email; your password hasn't changed.`,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alerts safety managers and admins about high and critical reports. */
|
||||||
|
incidentReported(incidentId: string) {
|
||||||
|
this.dispatch(async () => {
|
||||||
|
const [incident] = await this.db.select().from(incidents).where(eq(incidents.id, incidentId))
|
||||||
|
if (!incident || (incident.severity !== 'high' && incident.severity !== 'critical')) return
|
||||||
|
const recipients = await this.db
|
||||||
|
.select({ email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.organizationId, incident.organizationId),
|
||||||
|
eq(users.active, true),
|
||||||
|
inArray(users.role, ['safety_manager', 'admin']),
|
||||||
|
incident.reporterId ? ne(users.id, incident.reporterId) : undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const reference = incidentReference(incident.number)
|
||||||
|
for (const { email } of recipients) {
|
||||||
|
await this.mailer.send({
|
||||||
|
to: email,
|
||||||
|
subject: `[${severityLabel[incident.severity]}] ${reference}: ${incident.title}`,
|
||||||
|
text: `A ${severityLabel[incident.severity].toLowerCase()}-severity incident was reported.\n\n${reference}: ${incident.title}\nOccurred: ${incident.occurredAt.toLocaleString('en-US')}\n\n${this.link(`/incidents/${incident.number}`)}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1904.39: fatalities must be reported to OSHA within 8 hours; hospitalizations, amputations and eye losses within 24. */
|
||||||
|
severeInjury(caseId: string) {
|
||||||
|
this.dispatch(async () => {
|
||||||
|
const [row] = await this.db
|
||||||
|
.select({ person: injuredPersons, incident: incidents })
|
||||||
|
.from(injuredPersons)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||||
|
.where(eq(injuredPersons.id, caseId))
|
||||||
|
const report = row && severeInjuryReport(row.person)
|
||||||
|
if (!report) return
|
||||||
|
const recipients = await this.db
|
||||||
|
.select({ email: users.email })
|
||||||
|
.from(users)
|
||||||
|
.where(and(eq(users.organizationId, row.incident.organizationId), eq(users.active, true), inArray(users.role, ['safety_manager', 'admin'])))
|
||||||
|
const reference = incidentReference(row.incident.number)
|
||||||
|
for (const { email } of recipients) {
|
||||||
|
await this.mailer.send({
|
||||||
|
to: email,
|
||||||
|
subject: `Report to OSHA within ${report.withinHours} hours: ${reference}`,
|
||||||
|
text: `${reference} (${row.incident.title}) was recorded with: ${report.reasons.join(', ')}. OSHA requires a direct report within ${report.withinHours} hours of learning about it (29 CFR 1904.39).\n\n${OSHA_REPORTING_CONTACT}\n\nAfter reporting, record the date and time on the case: ${this.link(`/incidents/${row.incident.number}`)}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
actionAssigned(actionId: string, assignedBy: string) {
|
||||||
|
this.dispatch(async () => {
|
||||||
|
const [row] = await this.db
|
||||||
|
.select({ action: correctiveActions, email: users.email, number: incidents.number, incidentTitle: incidents.title })
|
||||||
|
.from(correctiveActions)
|
||||||
|
.innerJoin(users, eq(users.id, correctiveActions.assigneeId))
|
||||||
|
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||||
|
.where(and(eq(correctiveActions.id, actionId), eq(users.active, true)))
|
||||||
|
if (!row) return
|
||||||
|
const due = row.action.dueDate ? `Due: ${row.action.dueDate}\n` : ''
|
||||||
|
await this.mailer.send({
|
||||||
|
to: row.email,
|
||||||
|
subject: `Action assigned: ${row.action.title}`,
|
||||||
|
text: `${assignedBy} assigned you a corrective action on ${incidentReference(row.number)} (${row.incidentTitle}).\n\n${row.action.title}\n${due}\n${this.link(`/incidents/${row.number}`)}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emails each assignee a digest of open actions due tomorrow or earlier, at most once a day per action.
|
||||||
|
* Returns how many emails were queued.
|
||||||
|
*/
|
||||||
|
async sendDueReminders(today = localDate()): Promise<number> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ action: correctiveActions, email: users.email, number: incidents.number })
|
||||||
|
.from(correctiveActions)
|
||||||
|
.innerJoin(users, eq(users.id, correctiveActions.assigneeId))
|
||||||
|
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(correctiveActions.status, ['open', 'in_progress']),
|
||||||
|
eq(users.active, true),
|
||||||
|
isNotNull(correctiveActions.dueDate),
|
||||||
|
lte(correctiveActions.dueDate, addDays(today, 1)),
|
||||||
|
or(isNull(correctiveActions.reminderSentOn), lt(correctiveActions.reminderSentOn, today)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const byEmail = Map.groupBy(rows, (row) => row.email)
|
||||||
|
for (const [email, items] of byEmail) {
|
||||||
|
const lines = items.map(({ action, number }) => {
|
||||||
|
const when = action.dueDate! < today ? `OVERDUE since ${action.dueDate}` : action.dueDate === today ? 'due today' : 'due tomorrow'
|
||||||
|
return `- ${action.title} (${incidentReference(number)}), ${when}\n ${this.link(`/incidents/${number}`)}`
|
||||||
|
})
|
||||||
|
const overdue = items.filter(({ action }) => action.dueDate! < today).length
|
||||||
|
this.dispatch(() =>
|
||||||
|
this.mailer.send({
|
||||||
|
to: email,
|
||||||
|
subject: overdue ? `${overdue} overdue corrective action${overdue === 1 ? '' : 's'}` : 'Corrective actions due soon',
|
||||||
|
text: `These corrective actions assigned to you need attention:\n\n${lines.join('\n')}\n\nAll your actions: ${this.link('/actions')}`,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (rows.length) {
|
||||||
|
await this.db
|
||||||
|
.update(correctiveActions)
|
||||||
|
.set({ reminderSentOn: today })
|
||||||
|
.where(inArray(correctiveActions.id, rows.map(({ action }) => action.id)))
|
||||||
|
}
|
||||||
|
return byEmail.size
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { and, asc, eq, inArray, lt, sql } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { hasRole } from '../../shared/enums.ts'
|
||||||
|
import { actionListQuery, updateActionSchema } from '../../shared/schemas.ts'
|
||||||
|
import { requireAuth } from '../auth/middleware.ts'
|
||||||
|
import { correctiveActions, incidents, investigations, rootCauses } from '../db/schema.ts'
|
||||||
|
import { diff } from '../lib/audit.ts'
|
||||||
|
import { localDate } from '../lib/dates.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
import { assertActiveUser, auditIncident, selectActions, toAction } from '../lib/incidents.ts'
|
||||||
|
|
||||||
|
const idParam = z.object({ id: z.uuid() })
|
||||||
|
const OPEN = ['open', 'in_progress'] as const
|
||||||
|
|
||||||
|
export const actionRoutes = new Hono<AppEnv>()
|
||||||
|
.get('/', requireAuth, validate('query', actionListQuery), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const { scope, filter } = c.req.valid('query')
|
||||||
|
if (scope === 'all' && !hasRole(me.role, 'supervisor')) throw fail(403, 'You do not have permission to do that')
|
||||||
|
const today = localDate()
|
||||||
|
const rows = await selectActions(c.get('db'))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(incidents.organizationId, me.organizationId),
|
||||||
|
scope === 'mine' ? eq(correctiveActions.assigneeId, me.id) : undefined,
|
||||||
|
filter === 'open' ? inArray(correctiveActions.status, OPEN) : undefined,
|
||||||
|
filter === 'overdue' ? and(inArray(correctiveActions.status, OPEN), lt(correctiveActions.dueDate, today)) : undefined,
|
||||||
|
filter === 'done' ? inArray(correctiveActions.status, ['done', 'verified']) : undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(sql`${correctiveActions.dueDate} asc nulls last`, asc(correctiveActions.createdAt))
|
||||||
|
.limit(500)
|
||||||
|
return c.json({ actions: rows.map((row) => toAction(row, today)) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.patch('/:id', requireAuth, validate('param', idParam), validate('json', updateActionSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const [row] = await selectActions(db).where(and(eq(correctiveActions.id, c.req.valid('param').id), eq(incidents.organizationId, me.organizationId)))
|
||||||
|
if (!row) throw fail(404, 'Action not found')
|
||||||
|
const { action } = row
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
|
||||||
|
const isManager = hasRole(me.role, 'supervisor')
|
||||||
|
const isAssignee = action.assigneeId === me.id
|
||||||
|
if (!isManager) {
|
||||||
|
// Assignees can report progress on their own actions, nothing else.
|
||||||
|
const onlyStatus = Object.keys(input).every((key) => key === 'status')
|
||||||
|
if (!isAssignee || !onlyStatus || (input.status && !['open', 'in_progress', 'done'].includes(input.status))) {
|
||||||
|
throw fail(403, 'You can only update the progress of actions assigned to you')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.status === 'verified' && !hasRole(me.role, 'safety_manager')) throw fail(403, 'Only safety managers can verify actions')
|
||||||
|
if (action.status === 'verified' && input.status && input.status !== 'verified' && !hasRole(me.role, 'safety_manager')) {
|
||||||
|
throw fail(403, 'Only safety managers can reopen a verified action')
|
||||||
|
}
|
||||||
|
if (input.assigneeId) await assertActiveUser(db, me.organizationId, input.assigneeId)
|
||||||
|
if (input.rootCauseId) {
|
||||||
|
const [cause] = await db
|
||||||
|
.select({ id: rootCauses.id })
|
||||||
|
.from(rootCauses)
|
||||||
|
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||||
|
.where(and(eq(rootCauses.id, input.rootCauseId), eq(investigations.incidentId, action.incidentId)))
|
||||||
|
if (!cause) throw fail(400, 'Root cause must belong to this incident')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const status = input.status ?? action.status
|
||||||
|
const update = {
|
||||||
|
...input,
|
||||||
|
completedAt: status === 'done' || status === 'verified' ? (action.completedAt ?? now) : null,
|
||||||
|
verifiedAt: status === 'verified' ? (action.verifiedAt ?? now) : null,
|
||||||
|
verifiedById: status === 'verified' ? (action.verifiedById ?? me.id) : null,
|
||||||
|
// A new due date deserves a fresh reminder
|
||||||
|
reminderSentOn: input.dueDate !== undefined && input.dueDate !== action.dueDate ? null : undefined,
|
||||||
|
}
|
||||||
|
const changes = diff(action, update)
|
||||||
|
delete changes.reminderSentOn
|
||||||
|
if (Object.keys(changes).length > 0) {
|
||||||
|
await db.update(correctiveActions).set(update).where(eq(correctiveActions.id, action.id))
|
||||||
|
await auditIncident(db, me, me.organizationId, action.incidentId, 'action.update', { actionId: action.id, title: action.title, ...changes })
|
||||||
|
if (input.assigneeId && input.assigneeId !== action.assigneeId && input.assigneeId !== me.id) c.get('notifier').actionAssigned(action.id, me.name)
|
||||||
|
}
|
||||||
|
const [updated] = await selectActions(db).where(eq(correctiveActions.id, action.id))
|
||||||
|
return c.json({ action: toAction(updated) })
|
||||||
|
})
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { buckets } from '../lib/analytics.ts'
|
||||||
|
import { localDate } from '../lib/dates.ts'
|
||||||
|
import { createTestContext } from '../test/helpers.ts'
|
||||||
|
|
||||||
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||||
|
let manager: Awaited<ReturnType<typeof ctx.createUser>>
|
||||||
|
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
|
||||||
|
let north: { id: string }
|
||||||
|
let south: { id: string }
|
||||||
|
|
||||||
|
const daysAgo = (days: number, hour = 10) => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() - days)
|
||||||
|
d.setHours(hour, 0, 0, 0)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
const range = (fromDaysAgo: number, toDaysAgo = 0) => `from=${localDate(daysAgo(fromDaysAgo))}&to=${localDate(daysAgo(toDaysAgo))}`
|
||||||
|
|
||||||
|
async function report(type: string, when: Date, siteId = north.id, by = manager) {
|
||||||
|
const res = await by.post('/api/incidents', { title: `${type} report`, type, severity: 'low', occurredAt: when.toISOString(), siteId })
|
||||||
|
return res.body.incident.number as number
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createTestContext()
|
||||||
|
north = await ctx.createSite('North')
|
||||||
|
south = await ctx.createSite('South')
|
||||||
|
manager = await ctx.createUser('safety_manager')
|
||||||
|
reporter = await ctx.createUser('reporter')
|
||||||
|
// Current 30-day period: 3 near misses/hazards, 1 injury, 1 spill
|
||||||
|
await report('near_miss', daysAgo(2, 14))
|
||||||
|
await report('near_miss', daysAgo(3, 14))
|
||||||
|
await report('hazard', daysAgo(5, 9), south.id)
|
||||||
|
const injury = await report('injury', daysAgo(6, 22))
|
||||||
|
await report('environmental', daysAgo(10))
|
||||||
|
// Previous period: 1 near miss, 2 injuries
|
||||||
|
await report('near_miss', daysAgo(40))
|
||||||
|
await report('injury', daysAgo(45))
|
||||||
|
await report('injury', daysAgo(50))
|
||||||
|
|
||||||
|
// Triage one report straight away
|
||||||
|
await manager.post(`/api/incidents/${injury}/transitions`, { to: 'triage' })
|
||||||
|
// A recordable case with hours worked
|
||||||
|
await manager.post(`/api/osha/incidents/${injury}/cases`, { name: 'Case', workRelated: true, newCase: true, daysAwayFromWork: true, daysAway: 2 })
|
||||||
|
const now = new Date()
|
||||||
|
await manager.put(`/api/osha/sites/${north.id}/hours`, { year: now.getFullYear(), months: [{ month: now.getMonth() + 1, hours: 10_000, averageEmployees: 60 }] })
|
||||||
|
})
|
||||||
|
afterAll(() => ctx.close())
|
||||||
|
|
||||||
|
describe('analytics', () => {
|
||||||
|
it('compares the period with the one before', async () => {
|
||||||
|
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
|
||||||
|
expect(stats.kpis.reports).toEqual({ value: 5, previous: 3 })
|
||||||
|
expect(stats.kpis.leadingReports).toEqual({ value: 3, previous: 1 })
|
||||||
|
expect(stats.kpis.injuries).toEqual({ value: 1, previous: 2 })
|
||||||
|
expect(stats.kpis.nearMissRatio).toEqual({ value: 3, previous: 0.5 })
|
||||||
|
expect(stats.kpis.triagedWithin24h.value).toBeCloseTo(0.2)
|
||||||
|
expect(stats.kpis.recordables).toEqual({ value: 1, previous: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groups the trend, locations and the time-of-day heatmap', async () => {
|
||||||
|
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
|
||||||
|
expect(stats.bucket).toBe('week')
|
||||||
|
const totals = stats.trend.reduce((sum: Record<string, number>, b: Record<string, number>) => ({ hazards: sum.hazards + b.hazards, injuries: sum.injuries + b.injuries, damage: sum.damage + b.damage }), { hazards: 0, injuries: 0, damage: 0 })
|
||||||
|
expect(totals).toEqual({ hazards: 3, injuries: 1, damage: 1 })
|
||||||
|
expect(stats.byLocation).toEqual([
|
||||||
|
{ id: north.id, name: 'North', count: 4 },
|
||||||
|
{ id: south.id, name: 'South', count: 1 },
|
||||||
|
])
|
||||||
|
expect(stats.heatmap).toHaveLength(7)
|
||||||
|
expect(stats.heatmap.flat().reduce((a: number, b: number) => a + b, 0)).toBe(5)
|
||||||
|
expect(stats.heatmap[(daysAgo(6).getDay() + 6) % 7][22]).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('narrows to a site (showing its areas) and a type', async () => {
|
||||||
|
const stats = (await manager.get(`/api/stats/analytics?${range(29)}&siteId=${north.id}&type=near_miss`)).body
|
||||||
|
expect(stats.kpis.reports.value).toBe(2)
|
||||||
|
expect(stats.byLocation).toEqual([{ id: null, name: 'Area not specified', count: 2 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes rolling 12-month OSHA rates from hours worked', async () => {
|
||||||
|
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
|
||||||
|
expect(stats.rates).toHaveLength(12)
|
||||||
|
expect(stats.rates.at(-1)).toEqual({ month: localDate(new Date()).slice(0, 7), trir: 20, dart: 20 })
|
||||||
|
// Summed over the years, so the test holds even when the injury falls in last year (early January)
|
||||||
|
expect(stats.yearly.reduce((sum: number, y: { recordables: number }) => sum + y.recordables, 0)).toBe(1)
|
||||||
|
expect(stats.yearly.at(-1)).toMatchObject({ year: new Date().getFullYear(), hours: 10_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scopes reporters to their own reports and hides rates', async () => {
|
||||||
|
await report('hazard', daysAgo(1), north.id, reporter)
|
||||||
|
const stats = (await reporter.get(`/api/stats/analytics?${range(29)}`)).body
|
||||||
|
expect(stats.kpis.reports.value).toBe(1)
|
||||||
|
expect(stats.rates).toBeNull()
|
||||||
|
expect(stats.kpis.recordables).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates the range', async () => {
|
||||||
|
expect((await manager.get(`/api/stats/analytics?from=2026-02-01&to=2026-01-01`)).status).toBe(400)
|
||||||
|
expect((await manager.get(`/api/stats/analytics?from=2015-01-01&to=2026-01-01`)).status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses weeks for short ranges and months for long ones', () => {
|
||||||
|
expect(buckets('2026-01-01', '2026-03-31').bucket).toBe('week')
|
||||||
|
const long = buckets('2025-01-15', '2025-12-31')
|
||||||
|
expect(long.bucket).toBe('month')
|
||||||
|
expect(long.starts).toHaveLength(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('saved views', () => {
|
||||||
|
it('keeps private views private and lets supervisors share', async () => {
|
||||||
|
const supervisor = await ctx.createUser('supervisor')
|
||||||
|
const mine = await reporter.post('/api/views', { page: 'analytics', name: 'My hazards', filters: { type: 'hazard', range: '90' } })
|
||||||
|
expect(mine.status).toBe(201)
|
||||||
|
expect((await reporter.post('/api/views', { page: 'analytics', name: 'Shared?', filters: {}, shared: true })).status).toBe(403)
|
||||||
|
await supervisor.post('/api/views', { page: 'analytics', name: 'Plant review', filters: { range: '365' }, shared: true })
|
||||||
|
|
||||||
|
const reporterViews = (await reporter.get('/api/views?page=analytics')).body.views.map((v: { name: string; mine: boolean }) => [v.name, v.mine])
|
||||||
|
expect(reporterViews).toEqual([
|
||||||
|
['My hazards', true],
|
||||||
|
['Plant review', false],
|
||||||
|
])
|
||||||
|
expect((await supervisor.get('/api/views?page=analytics')).body.views.map((v: { name: string }) => v.name)).toEqual(['Plant review'])
|
||||||
|
|
||||||
|
expect((await reporter.delete(`/api/views/${(await supervisor.get('/api/views?page=analytics')).body.views[0].id}`)).status).toBe(403)
|
||||||
|
expect((await reporter.delete(`/api/views/${mine.body.view.id}`)).status).toBe(204)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createTestContext, hoursAgo } from '../test/helpers.ts'
|
||||||
|
|
||||||
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||||
|
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
|
||||||
|
let number: number
|
||||||
|
|
||||||
|
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
|
||||||
|
const form = (bytes: Uint8Array<ArrayBuffer>, name: string, type = 'image/png') => {
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('file', new File([bytes], name, { type }))
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createTestContext()
|
||||||
|
const site = await ctx.createSite()
|
||||||
|
reporter = await ctx.createUser('reporter')
|
||||||
|
const res = await reporter.post('/api/incidents', { title: 'Spill', type: 'hazard', severity: 'low', occurredAt: hoursAgo(1), siteId: site.id })
|
||||||
|
number = res.body.incident.number
|
||||||
|
})
|
||||||
|
afterAll(() => ctx.close())
|
||||||
|
|
||||||
|
describe('attachments', () => {
|
||||||
|
it('stores a photo and serves it back safely', async () => {
|
||||||
|
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'spill.png'))
|
||||||
|
expect(res.status).toBe(201)
|
||||||
|
expect(res.body.attachment).toMatchObject({ fileName: 'spill.png', contentType: 'image/png', sizeBytes: PNG.byteLength })
|
||||||
|
|
||||||
|
const file = await reporter.get(res.body.attachment.url)
|
||||||
|
expect(file.status).toBe(200)
|
||||||
|
expect(file.headers.get('content-type')).toBe('image/png')
|
||||||
|
expect(file.headers.get('content-security-policy')).toContain('sandbox')
|
||||||
|
expect(new Uint8Array(file.body)).toEqual(PNG)
|
||||||
|
|
||||||
|
const detail = await reporter.get(`/api/incidents/${number}`)
|
||||||
|
expect(detail.body.incident.attachments).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('identifies files by content, not by name or claimed type', async () => {
|
||||||
|
const html = new TextEncoder().encode('<script>alert(1)</script>')
|
||||||
|
expect((await reporter.post(`/api/incidents/${number}/attachments`, form(html, 'photo.png', 'image/png'))).status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects files over the size limit', async () => {
|
||||||
|
const big = new Uint8Array(ctx.config.maxUploadBytes + 1)
|
||||||
|
big.set(PNG)
|
||||||
|
expect((await reporter.post(`/api/incidents/${number}/attachments`, form(big, 'big.png'))).status).toBe(413)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides files from people who cannot see the incident', async () => {
|
||||||
|
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'second.png'))
|
||||||
|
const outsider = await ctx.createUser('reporter', 'outsider@example.com')
|
||||||
|
expect((await outsider.get(res.body.attachment.url)).status).toBe(404)
|
||||||
|
expect((await outsider.delete(res.body.attachment.url)).status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets the uploader remove their file', async () => {
|
||||||
|
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'remove-me.png'))
|
||||||
|
expect((await reporter.delete(res.body.attachment.url)).status).toBe(204)
|
||||||
|
expect((await reporter.get(res.body.attachment.url)).status).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('upload origin check', () => {
|
||||||
|
it('rejects uploads posted from another site', async () => {
|
||||||
|
const res = await ctx.app.request(`/api/incidents/${number}/attachments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { cookie: reporter.cookie!, origin: 'http://evil.example' },
|
||||||
|
body: form(PNG, 'x.png'),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { hasRole } from '../../shared/enums.ts'
|
||||||
|
import { requireAuth } from '../auth/middleware.ts'
|
||||||
|
import type { SessionUser } from '../auth/sessions.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { attachments, incidents } from '../db/schema.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
import { auditIncident, visibleTo } from '../lib/incidents.ts'
|
||||||
|
|
||||||
|
const idParam = z.object({ id: z.uuid() })
|
||||||
|
|
||||||
|
async function findAttachment(db: Db, user: SessionUser, id: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ file: attachments })
|
||||||
|
.from(attachments)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, attachments.incidentId))
|
||||||
|
.where(and(eq(attachments.id, id), visibleTo(db, user)))
|
||||||
|
if (!row) throw fail(404, 'Attachment not found')
|
||||||
|
return row.file
|
||||||
|
}
|
||||||
|
|
||||||
|
export const attachmentRoutes = new Hono<AppEnv>()
|
||||||
|
.get('/:id', requireAuth, validate('param', idParam), async (c) => {
|
||||||
|
const file = await findAttachment(c.get('db'), currentUser(c), c.req.valid('param').id)
|
||||||
|
const data = await c.get('storage').read(file.storageKey)
|
||||||
|
const disposition = file.contentType.startsWith('image/') ? 'inline' : 'attachment'
|
||||||
|
return c.body(new Uint8Array(data), 200, {
|
||||||
|
'Content-Type': file.contentType,
|
||||||
|
'Content-Length': String(data.byteLength),
|
||||||
|
'Content-Disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(file.fileName)}`,
|
||||||
|
// Uploaded content never runs scripts, even if a file slipped past type detection
|
||||||
|
'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox",
|
||||||
|
'Cache-Control': 'private, max-age=3600',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
.delete('/:id', requireAuth, validate('param', idParam), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const file = await findAttachment(db, me, c.req.valid('param').id)
|
||||||
|
if (file.uploadedById !== me.id && !hasRole(me.role, 'supervisor')) throw fail(403, 'Only the uploader or a supervisor can remove this file')
|
||||||
|
await db.delete(attachments).where(eq(attachments.id, file.id))
|
||||||
|
await c.get('storage').remove(file.storageKey)
|
||||||
|
await auditIncident(db, me, me.organizationId, file.incidentId, 'attachment.remove', { fileName: file.fileName })
|
||||||
|
return c.body(null, 204)
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { ADMIN, createTestContext } from '../test/helpers.ts'
|
||||||
|
|
||||||
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createTestContext()
|
||||||
|
})
|
||||||
|
afterAll(() => ctx.close())
|
||||||
|
|
||||||
|
describe('first-run setup', () => {
|
||||||
|
it('reports that setup is done and refuses to run again', async () => {
|
||||||
|
const client = ctx.anonymous()
|
||||||
|
expect((await client.get('/api/setup')).body).toEqual({ needsSetup: false })
|
||||||
|
const again = await client.post('/api/setup', { ...ADMIN, email: 'intruder@example.com' })
|
||||||
|
expect(again.status).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signed the new admin in', async () => {
|
||||||
|
const me = await ctx.admin.get('/api/auth/me')
|
||||||
|
expect(me.status).toBe(200)
|
||||||
|
expect(me.body.user).toMatchObject({ email: ADMIN.email, role: 'admin' })
|
||||||
|
expect(me.body.organization.name).toBe(ADMIN.organizationName)
|
||||||
|
expect(me.body.user.passwordHash).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sign in and out', () => {
|
||||||
|
it('rejects a wrong password with a generic message', async () => {
|
||||||
|
const res = await ctx.anonymous().post('/api/auth/login', { email: ADMIN.email, password: 'nope' })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toBe('Incorrect email or password')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats unknown emails the same as wrong passwords', async () => {
|
||||||
|
const res = await ctx.anonymous().post('/api/auth/login', { email: 'nobody@example.com', password: 'whatever' })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toBe('Incorrect email or password')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts email in any case and sets an http-only cookie', async () => {
|
||||||
|
const client = ctx.anonymous()
|
||||||
|
const res = await client.post('/api/auth/login', { email: ADMIN.email.toUpperCase(), password: ADMIN.password })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch(/HttpOnly/)
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch(/SameSite=Lax/)
|
||||||
|
expect((await client.get('/api/auth/me')).status).toBe(200)
|
||||||
|
|
||||||
|
expect((await client.post('/api/auth/logout')).status).toBe(204)
|
||||||
|
expect((await client.get('/api/auth/me')).status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates the session server-side on logout, even if the cookie is replayed', async () => {
|
||||||
|
const client = ctx.anonymous()
|
||||||
|
await client.post('/api/auth/login', { email: ADMIN.email, password: ADMIN.password })
|
||||||
|
const stolen = client.cookie
|
||||||
|
await client.post('/api/auth/logout')
|
||||||
|
client.cookie = stolen
|
||||||
|
expect((await client.get('/api/auth/me')).status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rate-limits repeated attempts for one email', async () => {
|
||||||
|
const client = ctx.anonymous()
|
||||||
|
const email = 'ratelimit@example.com'
|
||||||
|
for (let i = 0; i < 10; i++) expect((await client.post('/api/auth/login', { email, password: 'x' })).status).toBe(401)
|
||||||
|
expect((await client.post('/api/auth/login', { email, password: 'x' })).status).toBe(429)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks deactivated users and ends their sessions', async () => {
|
||||||
|
const worker = await ctx.createUser('reporter', 'leaver@example.com')
|
||||||
|
expect((await worker.get('/api/auth/me')).status).toBe(200)
|
||||||
|
await ctx.admin.patch(`/api/users/${worker.user.id}`, { active: false })
|
||||||
|
expect((await worker.get('/api/auth/me')).status).toBe(401)
|
||||||
|
expect((await ctx.anonymous().post('/api/auth/login', { email: 'leaver@example.com', password: 'a sufficiently long password' })).status).toBe(401)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('change password', () => {
|
||||||
|
it('requires the current password and signs out other sessions', async () => {
|
||||||
|
const user = await ctx.createUser('supervisor', 'changer@example.com')
|
||||||
|
const otherDevice = ctx.anonymous()
|
||||||
|
await otherDevice.post('/api/auth/login', { email: 'changer@example.com', password: 'a sufficiently long password' })
|
||||||
|
|
||||||
|
expect((await user.post('/api/auth/password', { currentPassword: 'wrong', newPassword: 'a brand new password' })).status).toBe(400)
|
||||||
|
expect((await user.post('/api/auth/password', { currentPassword: 'a sufficiently long password', newPassword: 'short' })).status).toBe(400)
|
||||||
|
expect((await user.post('/api/auth/password', { currentPassword: 'a sufficiently long password', newPassword: 'a brand new password' })).status).toBe(204)
|
||||||
|
|
||||||
|
expect((await user.get('/api/auth/me')).status).toBe(200)
|
||||||
|
expect((await otherDevice.get('/api/auth/me')).status).toBe(401)
|
||||||
|
expect((await ctx.anonymous().post('/api/auth/login', { email: 'changer@example.com', password: 'a brand new password' })).status).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { changePasswordSchema, emailOnlySchema, loginSchema, resetPasswordSchema, tokenSchema } from '../../shared/schemas.ts'
|
||||||
|
import { endSession, requireAuth, startSession } from '../auth/middleware.ts'
|
||||||
|
import { hashPassword, verifyAgainstDummy, verifyPassword } from '../auth/password.ts'
|
||||||
|
import { RateLimiter } from '../auth/rate-limit.ts'
|
||||||
|
import { deleteUserSessions } from '../auth/sessions.ts'
|
||||||
|
import { consumeAuthToken, createAuthToken } from '../auth/tokens.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { organizations, users } from '../db/schema.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
import { publicUser } from './users.ts'
|
||||||
|
|
||||||
|
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000
|
||||||
|
const RESET_TTL_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
async function activeUserByEmail(db: Db, email: string) {
|
||||||
|
const [user] = await db.select({ id: users.id, email: users.email, active: users.active }).from(users).where(eq(users.email, email))
|
||||||
|
return user?.active ? user : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authRoutes() {
|
||||||
|
// 10 failed-or-successful attempts per email per 15 minutes
|
||||||
|
const loginLimiter = new RateLimiter(10, 15 * 60 * 1000)
|
||||||
|
// Emailed links: 5 per email per 15 minutes
|
||||||
|
const emailLimiter = new RateLimiter(5, 15 * 60 * 1000)
|
||||||
|
|
||||||
|
return new Hono<AppEnv>()
|
||||||
|
.post('/login', validate('json', loginSchema), async (c) => {
|
||||||
|
const { email, password } = c.req.valid('json')
|
||||||
|
if (!loginLimiter.attempt(email)) throw fail(429, 'Too many sign-in attempts. Try again in 15 minutes.')
|
||||||
|
|
||||||
|
const db = c.get('db')
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.email, email))
|
||||||
|
const valid = user?.active && user.passwordHash ? await verifyPassword(password, user.passwordHash) : await verifyAgainstDummy(password)
|
||||||
|
if (!user || !valid) throw fail(401, 'Incorrect email or password')
|
||||||
|
|
||||||
|
loginLimiter.reset(email)
|
||||||
|
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id))
|
||||||
|
await startSession(c, user.id)
|
||||||
|
const [result] = await db.select(publicUser).from(users).where(eq(users.id, user.id))
|
||||||
|
return c.json({ user: result })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Always 204, so the response never reveals whether an account exists.
|
||||||
|
.post('/magic-link', validate('json', emailOnlySchema), async (c) => {
|
||||||
|
const { email } = c.req.valid('json')
|
||||||
|
if (!emailLimiter.attempt(`magic:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.')
|
||||||
|
const db = c.get('db')
|
||||||
|
const user = await activeUserByEmail(db, email)
|
||||||
|
if (user) c.get('notifier').magicLink(user.email, await createAuthToken(db, user.id, 'magic_link', MAGIC_LINK_TTL_MS))
|
||||||
|
return c.body(null, 204)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST rather than GET so email link scanners can't use up the token.
|
||||||
|
.post('/magic-link/verify', validate('json', tokenSchema), async (c) => {
|
||||||
|
const db = c.get('db')
|
||||||
|
const userId = await consumeAuthToken(db, c.req.valid('json').token, 'magic_link')
|
||||||
|
if (!userId) throw fail(400, 'This sign-in link is invalid or has expired. Request a new one.')
|
||||||
|
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId))
|
||||||
|
await startSession(c, userId)
|
||||||
|
const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId))
|
||||||
|
return c.json({ user })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/password-reset', validate('json', emailOnlySchema), async (c) => {
|
||||||
|
const { email } = c.req.valid('json')
|
||||||
|
if (!emailLimiter.attempt(`reset:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.')
|
||||||
|
const db = c.get('db')
|
||||||
|
const user = await activeUserByEmail(db, email)
|
||||||
|
if (user) c.get('notifier').passwordReset(user.email, await createAuthToken(db, user.id, 'password_reset', RESET_TTL_MS), null)
|
||||||
|
return c.body(null, 204)
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/password-reset/confirm', validate('json', resetPasswordSchema), async (c) => {
|
||||||
|
const { token, newPassword } = c.req.valid('json')
|
||||||
|
const db = c.get('db')
|
||||||
|
const userId = await consumeAuthToken(db, token, 'password_reset')
|
||||||
|
if (!userId) throw fail(400, 'This link is invalid or has expired. Request a new one.')
|
||||||
|
await db.update(users).set({ passwordHash: await hashPassword(newPassword), lastLoginAt: new Date() }).where(eq(users.id, userId))
|
||||||
|
await deleteUserSessions(db, userId)
|
||||||
|
await startSession(c, userId)
|
||||||
|
const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId))
|
||||||
|
return c.json({ user })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/logout', async (c) => {
|
||||||
|
await endSession(c)
|
||||||
|
return c.body(null, 204)
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/me', requireAuth, async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const [user] = await db.select(publicUser).from(users).where(eq(users.id, me.id))
|
||||||
|
const [organization] = await db.select({ id: organizations.id, name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId))
|
||||||
|
return c.json({ user, organization })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/password', requireAuth, validate('json', changePasswordSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const { currentPassword, newPassword } = c.req.valid('json')
|
||||||
|
const db = c.get('db')
|
||||||
|
const [user] = await db.select({ passwordHash: users.passwordHash }).from(users).where(eq(users.id, me.id))
|
||||||
|
if (!user.passwordHash || !(await verifyPassword(currentPassword, user.passwordHash))) throw fail(400, 'Current password is incorrect')
|
||||||
|
|
||||||
|
await db.update(users).set({ passwordHash: await hashPassword(newPassword) }).where(eq(users.id, me.id))
|
||||||
|
// Sign out everywhere else
|
||||||
|
await deleteUserSessions(db, me.id)
|
||||||
|
await startSession(c, me.id)
|
||||||
|
return c.body(null, 204)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createTestContext, tokenFrom } from '../test/helpers.ts'
|
||||||
|
|
||||||
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createTestContext()
|
||||||
|
await ctx.createUser('supervisor', 'linked@example.com')
|
||||||
|
})
|
||||||
|
afterAll(() => ctx.close())
|
||||||
|
|
||||||
|
const lastToken = async (email: string) => {
|
||||||
|
await ctx.notifier.flush()
|
||||||
|
return tokenFrom(ctx.mailer.last(email)?.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('magic links', () => {
|
||||||
|
it('signs in once with an emailed link', async () => {
|
||||||
|
const client = ctx.anonymous()
|
||||||
|
expect((await client.post('/api/auth/magic-link', { email: 'Linked@Example.com' })).status).toBe(204)
|
||||||
|
const token = await lastToken('linked@example.com')
|
||||||
|
expect(ctx.mailer.last('linked@example.com')?.text).toContain(`http://safety.test/auth/magic?token=${token}`)
|
||||||
|
|
||||||
|
const res = await client.post('/api/auth/magic-link/verify', { token })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.user.email).toBe('linked@example.com')
|
||||||
|
expect((await client.get('/api/auth/me')).status).toBe(200)
|
||||||
|
|
||||||
|
expect((await ctx.anonymous().post('/api/auth/magic-link/verify', { token })).status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers the same for unknown emails and sends nothing', async () => {
|
||||||
|
const before = ctx.mailer.sent.length
|
||||||
|
expect((await ctx.anonymous().post('/api/auth/magic-link', { email: 'nobody@example.com' })).status).toBe(204)
|
||||||
|
await ctx.notifier.flush()
|
||||||
|
expect(ctx.mailer.sent.length).toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("doesn't accept a magic link token as a password reset", async () => {
|
||||||
|
await ctx.anonymous().post('/api/auth/magic-link', { email: 'linked@example.com' })
|
||||||
|
const token = await lastToken('linked@example.com')
|
||||||
|
expect((await ctx.anonymous().post('/api/auth/password-reset/confirm', { token, newPassword: 'a whole new password' })).status).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('password reset', () => {
|
||||||
|
it('sets a new password and signs out other sessions', async () => {
|
||||||
|
const user = await ctx.createUser('reporter', 'forgetful@example.com')
|
||||||
|
await ctx.anonymous().post('/api/auth/password-reset', { email: 'forgetful@example.com' })
|
||||||
|
const token = await lastToken('forgetful@example.com')
|
||||||
|
|
||||||
|
const client = ctx.anonymous()
|
||||||
|
const res = await client.post('/api/auth/password-reset/confirm', { token, newPassword: 'remembered at last' })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect((await client.get('/api/auth/me')).status).toBe(200)
|
||||||
|
expect((await user.get('/api/auth/me')).status).toBe(401)
|
||||||
|
expect((await ctx.anonymous().post('/api/auth/login', { email: 'forgetful@example.com', password: 'remembered at last' })).status).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('invitations', () => {
|
||||||
|
it('emails new users without a password a link to set one', async () => {
|
||||||
|
const res = await ctx.admin.post('/api/users', { name: 'New Starter', email: 'starter@example.com', role: 'reporter' })
|
||||||
|
expect(res.body).toMatchObject({ invited: true })
|
||||||
|
await ctx.notifier.flush()
|
||||||
|
const mail = ctx.mailer.last('starter@example.com')
|
||||||
|
expect(mail?.subject).toBe("You've been invited to Northstar Co. on Open SafetyLog EHS")
|
||||||
|
|
||||||
|
// Can't sign in until the password is set
|
||||||
|
expect((await ctx.anonymous().post('/api/auth/login', { email: 'starter@example.com', password: 'anything at all' })).status).toBe(401)
|
||||||
|
const client = ctx.anonymous()
|
||||||
|
expect((await client.post('/api/auth/password-reset/confirm', { token: tokenFrom(mail?.text), newPassword: 'my first password' })).status).toBe(200)
|
||||||
|
expect((await client.get('/api/auth/me')).body.user.email).toBe('starter@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets admins set a password directly when email is not available', async () => {
|
||||||
|
const created = await ctx.admin.post('/api/users', { name: 'No Email', email: 'offline@example.com', role: 'reporter' })
|
||||||
|
await ctx.admin.patch(`/api/users/${created.body.user.id}`, { password: 'set by the admin' })
|
||||||
|
expect((await ctx.anonymous().post('/api/auth/login', { email: 'offline@example.com', password: 'set by the admin' })).status).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { auditLog } from '../db/schema.ts'
|
||||||
|
import { createTestContext, hoursAgo } from '../test/helpers.ts'
|
||||||
|
|
||||||
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||||
|
let site: { id: string }
|
||||||
|
let area: { id: string }
|
||||||
|
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
|
||||||
|
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
|
||||||
|
|
||||||
|
const report = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
title: 'Forklift reversed into pallet stack',
|
||||||
|
type: 'near_miss',
|
||||||
|
severity: 'high',
|
||||||
|
occurredAt: hoursAgo(2),
|
||||||
|
siteId: site.id,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createTestContext()
|
||||||
|
site = await ctx.createSite('North Campus')
|
||||||
|
area = (await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Loading bay C' })).body.area
|
||||||
|
reporter = await ctx.createUser('reporter')
|
||||||
|
supervisor = await ctx.createUser('supervisor')
|
||||||
|
})
|
||||||
|
afterAll(() => ctx.close())
|
||||||
|
|
||||||
|
describe('reporting', () => {
|
||||||
|
it('creates an incident with a sequential reference and the reporter recorded', async () => {
|
||||||
|
const res = await reporter.post('/api/incidents', report({ areaId: area.id, locationDetail: 'Dock door 3' }))
|
||||||
|
expect(res.status).toBe(201)
|
||||||
|
expect(res.body.incident).toMatchObject({
|
||||||
|
number: 1,
|
||||||
|
reference: 'INC-0001',
|
||||||
|
status: 'reported',
|
||||||
|
site: { id: site.id, name: 'North Campus' },
|
||||||
|
area: { id: area.id, name: 'Loading bay C' },
|
||||||
|
reporter: { id: reporter.user.id, name: 'reporter user' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('assigns distinct numbers to simultaneous reports', async () => {
|
||||||
|
const results = await Promise.all(Array.from({ length: 8 }, (_, i) => reporter.post('/api/incidents', report({ title: `Concurrent ${i}` }))))
|
||||||
|
const numbers = results.map((r) => r.body.incident.number)
|
||||||
|
expect(new Set(numbers).size).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps anonymous reports anonymous', async () => {
|
||||||
|
const res = await reporter.post('/api/incidents', report({ title: 'Blocked fire exit', anonymous: true }))
|
||||||
|
expect(res.body.incident).toMatchObject({ anonymous: true, reporter: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates location and time', async () => {
|
||||||
|
const otherSite = await ctx.createSite('South Campus')
|
||||||
|
expect((await reporter.post('/api/incidents', report({ siteId: otherSite.id, areaId: area.id }))).status).toBe(400)
|
||||||
|
expect((await reporter.post('/api/incidents', report({ occurredAt: new Date(Date.now() + 86_400_000).toISOString() }))).status).toBe(400)
|
||||||
|
expect((await reporter.post('/api/incidents', report({ type: 'explosion' }))).status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes an audit entry', async () => {
|
||||||
|
const res = await reporter.post('/api/incidents', report({ title: 'Audited' }))
|
||||||
|
const entries = await ctx.db.select().from(auditLog).where(eq(auditLog.entityId, res.body.incident.id))
|
||||||
|
expect(entries).toHaveLength(1)
|
||||||
|
expect(entries[0]).toMatchObject({ action: 'create', actorId: reporter.user.id })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('visibility', () => {
|
||||||
|
it('shows reporters only their own incidents', async () => {
|
||||||
|
const other = await ctx.createUser('reporter', 'other-reporter@example.com')
|
||||||
|
const mine = await other.post('/api/incidents', report({ title: 'Only mine' }))
|
||||||
|
const list = await other.get('/api/incidents')
|
||||||
|
expect(list.body.items.map((i: { title: string }) => i.title)).toEqual(['Only mine'])
|
||||||
|
expect((await reporter.get(`/api/incidents/${mine.body.incident.number}`)).status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows supervisors every incident', async () => {
|
||||||
|
const list = await supervisor.get('/api/incidents?pageSize=100')
|
||||||
|
expect(list.body.total).toBeGreaterThan(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listing', () => {
|
||||||
|
it('paginates newest first', async () => {
|
||||||
|
const page1 = await supervisor.get('/api/incidents?pageSize=3&page=1')
|
||||||
|
const page2 = await supervisor.get('/api/incidents?pageSize=3&page=2')
|
||||||
|
expect(page1.body.items).toHaveLength(3)
|
||||||
|
expect(page1.body.items[0].id).not.toBe(page2.body.items[0].id)
|
||||||
|
const times = page1.body.items.map((i: { occurredAt: string }) => i.occurredAt)
|
||||||
|
expect([...times].sort().reverse()).toEqual(times)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('searches title and reference, treating % and _ literally', async () => {
|
||||||
|
await reporter.post('/api/incidents', report({ title: 'Oil leak under press 4', type: 'hazard', severity: 'medium' }))
|
||||||
|
expect((await supervisor.get('/api/incidents?q=press')).body.items[0].title).toBe('Oil leak under press 4')
|
||||||
|
expect((await supervisor.get('/api/incidents?q=INC-0001')).body.items[0].number).toBe(1)
|
||||||
|
expect((await supervisor.get('/api/incidents?q=%25')).body.total).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by type, severity and status', async () => {
|
||||||
|
const hazards = await supervisor.get('/api/incidents?type=hazard&severity=medium')
|
||||||
|
expect(hazards.body.items.every((i: { type: string; severity: string }) => i.type === 'hazard' && i.severity === 'medium')).toBe(true)
|
||||||
|
expect((await supervisor.get('/api/incidents?status=closed')).body.total).toBe(0)
|
||||||
|
expect((await supervisor.get('/api/incidents?status=bogus')).status).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updating', () => {
|
||||||
|
it('lets supervisors edit details, recording what changed', async () => {
|
||||||
|
const created = await reporter.post('/api/incidents', report({ title: 'Wet floor' }))
|
||||||
|
const number = created.body.incident.number
|
||||||
|
expect((await reporter.patch(`/api/incidents/${number}`, { severity: 'low' })).status).toBe(403)
|
||||||
|
|
||||||
|
const edited = await supervisor.patch(`/api/incidents/${number}`, { severity: 'low', title: 'Wet floor by entrance' })
|
||||||
|
expect(edited.status).toBe(200)
|
||||||
|
expect(edited.body.incident).toMatchObject({ severity: 'low', title: 'Wet floor by entrance' })
|
||||||
|
|
||||||
|
const [entry] = await ctx.db.select().from(auditLog).where(eq(auditLog.action, 'update'))
|
||||||
|
expect(entry.changes).toMatchObject({ severity: ['high', 'low'], title: ['Wet floor', 'Wet floor by entrance'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not change status through edits', async () => {
|
||||||
|
const created = await reporter.post('/api/incidents', report())
|
||||||
|
const res = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { status: 'closed' })
|
||||||
|
expect(res.body.incident.status).toBe('reported')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the area when moving an incident to another site', async () => {
|
||||||
|
const created = await reporter.post('/api/incidents', report({ areaId: area.id }))
|
||||||
|
const west = await ctx.createSite('West Campus')
|
||||||
|
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: west.id })
|
||||||
|
expect(moved.body.incident).toMatchObject({ site: { id: west.id }, area: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('notifications', () => {
|
||||||
|
it('alerts safety managers about high-severity reports, not low ones', async () => {
|
||||||
|
const manager = await ctx.createUser('safety_manager', 'alerts@example.com')
|
||||||
|
await reporter.post('/api/incidents', report({ title: 'Arm caught in conveyor', type: 'injury', severity: 'critical' }))
|
||||||
|
await reporter.post('/api/incidents', report({ title: 'Scuffed paint', severity: 'low' }))
|
||||||
|
await ctx.notifier.flush()
|
||||||
|
const mails = ctx.mailer.sent.filter((mail) => mail.to === manager.user.email)
|
||||||
|
expect(mails).toHaveLength(1)
|
||||||
|
expect(mails[0].subject).toMatch(/^\[Critical\] INC-\d{4}: Arm caught in conveyor$/)
|
||||||
|
expect(mails[0].text).toContain('http://safety.test/incidents/')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('export', () => {
|
||||||
|
it('exports the filtered list as CSV, neutralising spreadsheet formulas', async () => {
|
||||||
|
await reporter.post('/api/incidents', report({ title: '=HYPERLINK("http://evil")', type: 'environmental', severity: 'low' }))
|
||||||
|
const res = await supervisor.get('/api/incidents/export?type=environmental')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.headers.get('content-type')).toContain('text/csv')
|
||||||
|
const csv = res.body.toString('utf8')
|
||||||
|
const lines = csv.trimEnd().split('\r\n')
|
||||||
|
expect(lines[0]).toMatch(/^\uFEFFReference,Title,Type/)
|
||||||
|
expect(lines).toHaveLength(2)
|
||||||
|
expect(lines[1]).toContain(`"'=HYPERLINK(""http://evil"")"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only exports what the user can see', async () => {
|
||||||
|
const other = await ctx.createUser('reporter', 'csv-reporter@example.com')
|
||||||
|
const res = await other.get('/api/incidents/export')
|
||||||
|
expect(res.body.toString('utf8').trimEnd().split('\r\n')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { and, asc, count, desc, eq, ilike, or, type SQL } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { bodyLimit } from 'hono/body-limit'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { Comment, TimelineEntry } from '../../shared/api-types.ts'
|
||||||
|
import { hasRole } from '../../shared/enums.ts'
|
||||||
|
import {
|
||||||
|
commentSchema,
|
||||||
|
createActionSchema,
|
||||||
|
createIncidentSchema,
|
||||||
|
incidentListQuery,
|
||||||
|
investigationSchema,
|
||||||
|
rootCauseSchema,
|
||||||
|
transitionSchema,
|
||||||
|
updateIncidentSchema,
|
||||||
|
type IncidentListQuery,
|
||||||
|
} from '../../shared/schemas.ts'
|
||||||
|
import { transitionBlocker, transitions } from '../../shared/workflow.ts'
|
||||||
|
import { requireAuth, requireRole } from '../auth/middleware.ts'
|
||||||
|
import type { SessionUser } from '../auth/sessions.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { auditLog, comments, correctiveActions, incidents, investigations, rootCauses, users } from '../db/schema.ts'
|
||||||
|
import { diff } from '../lib/audit.ts'
|
||||||
|
import { toCsv } from '../lib/csv.ts'
|
||||||
|
import { saveAttachment } from '../lib/attachments.ts'
|
||||||
|
import { iso } from '../lib/dates.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
import { assertActiveUser, auditIncident, findIncident, loadDetail, selectIncidents, toAttachment, toIncident, visibleTo } from '../lib/incidents.ts'
|
||||||
|
import { assertLocation, fileReport, parseOccurredAt } from '../lib/reporting.ts'
|
||||||
|
|
||||||
|
const numberParam = z.object({ number: z.coerce.number().int().positive() })
|
||||||
|
|
||||||
|
const EXPORT_LIMIT = 10_000
|
||||||
|
|
||||||
|
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
|
||||||
|
|
||||||
|
function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) {
|
||||||
|
let search: SQL | undefined
|
||||||
|
if (q) {
|
||||||
|
const pattern = `%${escapeLike(q)}%`
|
||||||
|
const number = /^(?:inc-?)?(\d+)$/i.exec(q)?.[1]
|
||||||
|
search = or(
|
||||||
|
ilike(incidents.title, pattern),
|
||||||
|
ilike(incidents.description, pattern),
|
||||||
|
ilike(incidents.locationDetail, pattern),
|
||||||
|
number ? eq(incidents.number, Number(number)) : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return and(
|
||||||
|
visibleTo(db, user),
|
||||||
|
search,
|
||||||
|
type ? eq(incidents.type, type) : undefined,
|
||||||
|
severity ? eq(incidents.severity, severity) : undefined,
|
||||||
|
status ? eq(incidents.status, status) : undefined,
|
||||||
|
siteId ? eq(incidents.siteId, siteId) : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the incident's investigation if it doesn't exist yet and returns its id. */
|
||||||
|
async function ensureInvestigation(db: Db, incidentId: string): Promise<string> {
|
||||||
|
await db.insert(investigations).values({ incidentId }).onConflictDoNothing({ target: investigations.incidentId })
|
||||||
|
const [row] = await db.select({ id: investigations.id }).from(investigations).where(eq(investigations.incidentId, incidentId))
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
|
export const incidentRoutes = new Hono<AppEnv>()
|
||||||
|
.get('/', requireAuth, validate('query', incidentListQuery), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const { page, pageSize, ...filters } = c.req.valid('query')
|
||||||
|
const db = c.get('db')
|
||||||
|
const where = listFilter(db, me, filters)
|
||||||
|
const [{ total }] = await db.select({ total: count() }).from(incidents).where(where)
|
||||||
|
const rows = await selectIncidents(db)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(incidents.occurredAt), desc(incidents.number))
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset((page - 1) * pageSize)
|
||||||
|
return c.json({ items: rows.map(toIncident), page, pageSize, total })
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/export', requireAuth, validate('query', incidentListQuery.omit({ page: true, pageSize: true })), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const rows = await selectIncidents(db)
|
||||||
|
.where(listFilter(db, me, c.req.valid('query')))
|
||||||
|
.orderBy(desc(incidents.occurredAt), desc(incidents.number))
|
||||||
|
.limit(EXPORT_LIMIT)
|
||||||
|
const csv = toCsv(
|
||||||
|
['Reference', 'Title', 'Type', 'Severity', 'Status', 'Occurred at', 'Reported at', 'Closed at', 'Site', 'Area', 'Location detail', 'Reporter', 'Description', 'Immediate action'],
|
||||||
|
rows.map(toIncident).map((i) => [
|
||||||
|
i.reference,
|
||||||
|
i.title,
|
||||||
|
i.type,
|
||||||
|
i.severity,
|
||||||
|
i.status,
|
||||||
|
i.occurredAt,
|
||||||
|
i.reportedAt,
|
||||||
|
i.closedAt,
|
||||||
|
i.site.name,
|
||||||
|
i.area?.name,
|
||||||
|
i.locationDetail,
|
||||||
|
i.anonymous ? 'Anonymous' : (i.reporter?.name ?? i.reporterName ?? (i.kiosk ? `Reporting link: ${i.kiosk.name}` : null)),
|
||||||
|
i.description,
|
||||||
|
i.immediateAction,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
const date = new Date().toISOString().slice(0, 10)
|
||||||
|
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="incidents-${date}.csv"` })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/', requireAuth, validate('json', createIncidentSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
const db = c.get('db')
|
||||||
|
const { id, created } = await fileReport(db, c.get('notifier'), { ...input, organizationId: me.organizationId, reporter: me })
|
||||||
|
const [row] = await selectIncidents(db).where(eq(incidents.id, id))
|
||||||
|
return c.json({ incident: toIncident(row) }, created ? 201 : 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/:number', requireAuth, validate('param', numberParam), async (c) => {
|
||||||
|
return c.json({ incident: await loadDetail(c.get('db'), currentUser(c), c.req.valid('param').number) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.patch('/:number', requireRole('supervisor'), validate('param', numberParam), validate('json', updateIncidentSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||||
|
const { occurredAt, ...input } = c.req.valid('json')
|
||||||
|
|
||||||
|
const siteId = input.siteId ?? incident.siteId
|
||||||
|
let areaId = input.areaId !== undefined ? input.areaId : incident.areaId
|
||||||
|
if (input.siteId && input.siteId !== incident.siteId && input.areaId === undefined) areaId = null // moving site clears the old area
|
||||||
|
if (input.siteId !== undefined || input.areaId !== undefined) await assertLocation(db, me.organizationId, siteId, areaId)
|
||||||
|
|
||||||
|
const update = { ...input, areaId, occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined }
|
||||||
|
const changes = diff(incident, update)
|
||||||
|
if (Object.keys(changes).length > 0) {
|
||||||
|
await db.update(incidents).set(update).where(eq(incidents.id, incident.id))
|
||||||
|
await auditIncident(db, me, me.organizationId, incident.id, 'update', changes)
|
||||||
|
}
|
||||||
|
return c.json({ incident: await loadDetail(db, me, incident.number) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/:number/transitions', requireAuth, validate('param', numberParam), validate('json', transitionSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { to, note } = c.req.valid('json')
|
||||||
|
const detail = await loadDetail(db, me, c.req.valid('param').number)
|
||||||
|
|
||||||
|
const rule = transitions.find((t) => t.from === detail.status && t.to === to)
|
||||||
|
if (!rule) throw fail(409, `An incident in "${detail.status}" can't move to "${to}"`)
|
||||||
|
if (!hasRole(me.role, rule.minRole)) throw fail(403, 'You do not have permission to do that')
|
||||||
|
const blocker = transitionBlocker(rule, {
|
||||||
|
severity: detail.severity,
|
||||||
|
rootCauseCount: detail.rootCauses.length,
|
||||||
|
actionStatuses: detail.actions.map((action) => action.status),
|
||||||
|
})
|
||||||
|
if (blocker) throw fail(409, blocker)
|
||||||
|
|
||||||
|
// Conditional update guards against two people moving the same incident at once.
|
||||||
|
const moved = await db
|
||||||
|
.update(incidents)
|
||||||
|
.set({ status: to, closedAt: to === 'closed' ? new Date() : null })
|
||||||
|
.where(and(eq(incidents.id, detail.id), eq(incidents.status, detail.status)))
|
||||||
|
.returning({ id: incidents.id })
|
||||||
|
if (moved.length === 0) throw fail(409, 'This incident was just changed by someone else. Reload and try again.')
|
||||||
|
|
||||||
|
await auditIncident(db, me, me.organizationId, detail.id, 'status', { status: [detail.status, to], ...(note ? { note } : {}) })
|
||||||
|
if (note) await db.insert(comments).values({ incidentId: detail.id, authorId: me.id, body: note })
|
||||||
|
return c.json({ incident: await loadDetail(db, me, detail.number) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/:number/comments', requireAuth, validate('param', numberParam), async (c) => {
|
||||||
|
const db = c.get('db')
|
||||||
|
const { incident } = await findIncident(db, currentUser(c), c.req.valid('param').number)
|
||||||
|
const rows = await db
|
||||||
|
.select({ comment: comments, authorName: users.name })
|
||||||
|
.from(comments)
|
||||||
|
.leftJoin(users, eq(users.id, comments.authorId))
|
||||||
|
.where(eq(comments.incidentId, incident.id))
|
||||||
|
.orderBy(asc(comments.createdAt))
|
||||||
|
const result: Comment[] = rows.map(({ comment, authorName }) => ({
|
||||||
|
id: comment.id,
|
||||||
|
author: comment.authorId ? { id: comment.authorId, name: authorName ?? '' } : null,
|
||||||
|
body: comment.body,
|
||||||
|
createdAt: iso(comment.createdAt),
|
||||||
|
}))
|
||||||
|
return c.json({ comments: result })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/:number/comments', requireAuth, validate('param', numberParam), validate('json', commentSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||||
|
const [comment] = await db.insert(comments).values({ incidentId: incident.id, authorId: me.id, body: c.req.valid('json').body }).returning()
|
||||||
|
const result: Comment = { id: comment.id, author: { id: me.id, name: me.name }, body: comment.body, createdAt: iso(comment.createdAt) }
|
||||||
|
return c.json({ comment: result }, 201)
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/:number/timeline', requireAuth, validate('param', numberParam), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||||
|
const rows = await db
|
||||||
|
.select({ entry: auditLog, actorName: users.name })
|
||||||
|
.from(auditLog)
|
||||||
|
.leftJoin(users, eq(users.id, auditLog.actorId))
|
||||||
|
.where(and(eq(auditLog.organizationId, me.organizationId), eq(auditLog.entityType, 'incident'), eq(auditLog.entityId, incident.id)))
|
||||||
|
.orderBy(asc(auditLog.id))
|
||||||
|
const timeline: TimelineEntry[] = rows.map(({ entry, actorName }) => ({
|
||||||
|
id: entry.id,
|
||||||
|
at: iso(entry.createdAt),
|
||||||
|
actor: entry.actorId ? { id: entry.actorId, name: actorName ?? '' } : null,
|
||||||
|
action: entry.action,
|
||||||
|
changes: (entry.changes as Record<string, unknown> | null) ?? null,
|
||||||
|
}))
|
||||||
|
return c.json({ timeline })
|
||||||
|
})
|
||||||
|
|
||||||
|
.put('/:number/investigation', requireRole('supervisor'), validate('param', numberParam), validate('json', investigationSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||||
|
const { completed, ...input } = c.req.valid('json')
|
||||||
|
if (input.leadId) await assertActiveUser(db, me.organizationId, input.leadId)
|
||||||
|
|
||||||
|
const investigationId = await ensureInvestigation(db, incident.id)
|
||||||
|
const [current] = await db.select().from(investigations).where(eq(investigations.id, investigationId))
|
||||||
|
const update = { ...input, completedAt: completed === undefined ? undefined : completed ? (current.completedAt ?? new Date()) : null }
|
||||||
|
const changes = diff(current, update)
|
||||||
|
if (Object.keys(changes).length > 0) {
|
||||||
|
await db.update(investigations).set(update).where(eq(investigations.id, investigationId))
|
||||||
|
await auditIncident(db, me, me.organizationId, incident.id, 'investigation.update', changes)
|
||||||
|
}
|
||||||
|
return c.json({ incident: await loadDetail(db, me, incident.number) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/:number/root-causes', requireRole('supervisor'), validate('param', numberParam), validate('json', rootCauseSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
const investigationId = await ensureInvestigation(db, incident.id)
|
||||||
|
await db.insert(rootCauses).values({ ...input, investigationId })
|
||||||
|
await auditIncident(db, me, me.organizationId, incident.id, 'root_cause.add', { category: input.category, description: input.description })
|
||||||
|
return c.json({ incident: await loadDetail(db, me, incident.number) }, 201)
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/:number/actions', requireRole('supervisor'), validate('param', numberParam), validate('json', createActionSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||||
|
if (incident.status === 'closed') throw fail(409, 'Reopen the incident before adding actions')
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
if (input.assigneeId) await assertActiveUser(db, me.organizationId, input.assigneeId)
|
||||||
|
if (input.rootCauseId) {
|
||||||
|
const [cause] = await db
|
||||||
|
.select({ id: rootCauses.id })
|
||||||
|
.from(rootCauses)
|
||||||
|
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||||
|
.where(and(eq(rootCauses.id, input.rootCauseId), eq(investigations.incidentId, incident.id)))
|
||||||
|
if (!cause) throw fail(400, 'Root cause must belong to this incident')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [action] = await db
|
||||||
|
.insert(correctiveActions)
|
||||||
|
.values({ ...input, incidentId: incident.id })
|
||||||
|
.returning()
|
||||||
|
await auditIncident(db, me, me.organizationId, incident.id, 'action.add', { actionId: action.id, title: action.title, assigneeId: action.assigneeId, dueDate: action.dueDate })
|
||||||
|
if (action.assigneeId && action.assigneeId !== me.id) c.get('notifier').actionAssigned(action.id, me.name)
|
||||||
|
return c.json({ incident: await loadDetail(db, me, incident.number) }, 201)
|
||||||
|
})
|
||||||
|
|
||||||
|
.post(
|
||||||
|
'/:number/attachments',
|
||||||
|
requireAuth,
|
||||||
|
validate('param', numberParam),
|
||||||
|
// Reject oversized bodies before reading them into memory
|
||||||
|
async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes + 64 * 1024, onError: (c) => c.json({ error: 'File is too large' }, 413) })(c, next),
|
||||||
|
async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const config = c.get('config')
|
||||||
|
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||||
|
|
||||||
|
const body = await c.req.parseBody()
|
||||||
|
const row = await saveAttachment({ db, storage: c.get('storage'), config }, incident.id, body.file, me.id)
|
||||||
|
await auditIncident(db, me, me.organizationId, incident.id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
|
||||||
|
return c.json({ attachment: toAttachment(row, me.name) }, 201)
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// oxlint-disable typescript/no-explicit-any -- tests read arbitrary JSON
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createTestContext, hoursAgo } from '../test/helpers.ts'
|
||||||
|
|
||||||
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||||
|
let north: { id: string }
|
||||||
|
let south: { id: string }
|
||||||
|
|
||||||
|
const json = (res: Response): Promise<any> => res.json()
|
||||||
|
|
||||||
|
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
|
||||||
|
|
||||||
|
/** Submits like the kiosk page: multipart with the token header. */
|
||||||
|
function kioskReport(token: string, report: Record<string, unknown>, files: File[] = []) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('report', JSON.stringify(report))
|
||||||
|
for (const file of files) form.append('files', file)
|
||||||
|
return ctx.app.request('/api/kiosk/reports', { method: 'POST', headers: { 'x-kiosk-token': token, origin: 'http://safety.test' }, body: form })
|
||||||
|
}
|
||||||
|
const report = (overrides: Record<string, unknown> = {}) => ({ title: 'Leaking valve', type: 'hazard', severity: 'medium', occurredAt: hoursAgo(1), siteId: north.id, ...overrides })
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createTestContext()
|
||||||
|
north = await ctx.createSite('North')
|
||||||
|
south = await ctx.createSite('South')
|
||||||
|
})
|
||||||
|
afterAll(() => ctx.close())
|
||||||
|
|
||||||
|
describe('reporting links', () => {
|
||||||
|
it('are managed by safety managers', async () => {
|
||||||
|
const supervisor = await ctx.createUser('supervisor')
|
||||||
|
expect((await supervisor.post('/api/kiosks', { name: 'Canteen tablet' })).status).toBe(403)
|
||||||
|
const res = await ctx.admin.post('/api/kiosks', { name: 'Canteen tablet' })
|
||||||
|
expect(res.status).toBe(201)
|
||||||
|
expect(res.body.kiosk.token).toMatch(/^[\w-]{32}$/)
|
||||||
|
expect((await ctx.admin.get('/api/kiosks')).body.kiosks.map((k: { name: string }) => k.name)).toContain('Canteen tablet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('let anyone with the token file a report, with photos, and nothing more', async () => {
|
||||||
|
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Gate 2' })).body
|
||||||
|
|
||||||
|
const config = await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } })
|
||||||
|
expect(await json(config)).toMatchObject({ organization: { name: 'Northstar Co.' }, kiosk: { name: 'Gate 2' } })
|
||||||
|
|
||||||
|
const res = await kioskReport(kiosk.token, report({ reporterName: 'Luis' }), [new File([PNG], 'valve.png', { type: 'image/png' })])
|
||||||
|
expect(res.status).toBe(201)
|
||||||
|
const { reference, failedFiles } = await json(res)
|
||||||
|
expect(reference).toMatch(/^INC-\d{4}$/)
|
||||||
|
expect(failedFiles).toEqual([])
|
||||||
|
|
||||||
|
const number = Number(reference.slice(4))
|
||||||
|
const incident = (await ctx.admin.get(`/api/incidents/${number}`)).body.incident
|
||||||
|
expect(incident).toMatchObject({ channel: 'kiosk', kiosk: { name: 'Gate 2' }, reporterName: 'Luis', reporter: null, anonymous: false })
|
||||||
|
expect(incident.attachments).toHaveLength(1)
|
||||||
|
|
||||||
|
// The token is not a session
|
||||||
|
expect((await ctx.app.request('/api/incidents', { headers: { 'x-kiosk-token': kiosk.token } })).status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treat reports without a name as anonymous', async () => {
|
||||||
|
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Anon' })).body
|
||||||
|
const { reference } = await json(await kioskReport(kiosk.token, report()))
|
||||||
|
const incident = (await ctx.admin.get(`/api/incidents/${Number(reference.slice(4))}`)).body.incident
|
||||||
|
expect(incident).toMatchObject({ anonymous: true, reporterName: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stop working when revoked', async () => {
|
||||||
|
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Old tablet' })).body
|
||||||
|
await ctx.admin.patch(`/api/kiosks/${kiosk.id}`, { revoked: true })
|
||||||
|
expect((await kioskReport(kiosk.token, report())).status).toBe(401)
|
||||||
|
expect((await kioskReport('made-up-token', report())).status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be limited to one site', async () => {
|
||||||
|
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'North only', siteId: north.id })).body
|
||||||
|
const config = await json(await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } }))
|
||||||
|
expect(config.sites.map((s: { id: string }) => s.id)).toEqual([north.id])
|
||||||
|
expect((await kioskReport(kiosk.token, report({ siteId: south.id }))).status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validate reports', async () => {
|
||||||
|
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Validation' })).body
|
||||||
|
const res = await kioskReport(kiosk.token, report({ type: 'explosion' }))
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect((await json(res)).issues[0].path).toBe('type')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('are rate limited', async () => {
|
||||||
|
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Busy' })).body
|
||||||
|
for (let i = 0; i < 30; i++) expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(201)
|
||||||
|
expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(429)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resubmitting the same report (offline retries)', () => {
|
||||||
|
it('returns the original instead of creating a duplicate', async () => {
|
||||||
|
const reporter = await ctx.createUser('reporter')
|
||||||
|
const clientId = crypto.randomUUID()
|
||||||
|
const first = await reporter.post('/api/incidents', { ...report(), clientId })
|
||||||
|
const again = await reporter.post('/api/incidents', { ...report(), clientId })
|
||||||
|
expect(first.status).toBe(201)
|
||||||
|
expect(again.status).toBe(200)
|
||||||
|
expect(again.body.incident.number).toBe(first.body.incident.number)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('holds under simultaneous retries', async () => {
|
||||||
|
const reporter = await ctx.createUser('reporter', 'racer@example.com')
|
||||||
|
const clientId = crypto.randomUUID()
|
||||||
|
const results = await Promise.all([1, 2, 3].map(() => reporter.post('/api/incidents', { ...report(), clientId })))
|
||||||
|
expect(new Set(results.map((r) => r.body.incident.number)).size).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not attach photos twice from a kiosk retry', async () => {
|
||||||
|
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Retry' })).body
|
||||||
|
const clientId = crypto.randomUUID()
|
||||||
|
const file = () => new File([PNG], 'p.png', { type: 'image/png' })
|
||||||
|
const a = await json(await kioskReport(kiosk.token, report({ clientId }), [file()]))
|
||||||
|
const b = await kioskReport(kiosk.token, report({ clientId }), [file()])
|
||||||
|
expect(b.status).toBe(200)
|
||||||
|
expect((await json(b)).reference).toBe(a.reference)
|
||||||
|
const incident = (await ctx.admin.get(`/api/incidents/${Number(a.reference.slice(4))}`)).body.incident
|
||||||
|
expect(incident.attachments).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
|
||||||
|
import { Hono, type Context } from 'hono'
|
||||||
|
import { bodyLimit } from 'hono/body-limit'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { Kiosk, KioskConfig } from '../../shared/api-types.ts'
|
||||||
|
import { incidentReference } from '../../shared/format.ts'
|
||||||
|
import { createKioskSchema, kioskReportSchema, updateKioskSchema } from '../../shared/schemas.ts'
|
||||||
|
import { requireRole } from '../auth/middleware.ts'
|
||||||
|
import { RateLimiter } from '../auth/rate-limit.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { areas, kiosks, organizations, sites } from '../db/schema.ts'
|
||||||
|
import { saveAttachment } from '../lib/attachments.ts'
|
||||||
|
import { audit, diff } from '../lib/audit.ts'
|
||||||
|
import { iso } from '../lib/dates.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
import { auditIncident } from '../lib/incidents.ts'
|
||||||
|
import { fileReport } from '../lib/reporting.ts'
|
||||||
|
|
||||||
|
export const KIOSK_HEADER = 'x-kiosk-token'
|
||||||
|
const MAX_FILES_PER_REPORT = 5
|
||||||
|
const idParam = z.object({ id: z.uuid() })
|
||||||
|
|
||||||
|
async function toKiosk(db: Db, row: typeof kiosks.$inferSelect): Promise<Kiosk> {
|
||||||
|
const [site] = row.siteId ? await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.id, row.siteId)) : []
|
||||||
|
return { id: row.id, name: row.name, token: row.token, site: site ?? null, lastUsedAt: iso(row.lastUsedAt), revokedAt: iso(row.revokedAt), createdAt: iso(row.createdAt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertSite(db: Db, organizationId: string, siteId: string | null | undefined) {
|
||||||
|
if (!siteId) return
|
||||||
|
const [site] = await db.select({ id: sites.id }).from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId)))
|
||||||
|
if (!site) throw fail(400, 'Unknown site')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Managing reporting links (signed-in safety managers). */
|
||||||
|
export const kioskAdminRoutes = new Hono<AppEnv>()
|
||||||
|
.get('/', requireRole('safety_manager'), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const rows = await db.select().from(kiosks).where(eq(kiosks.organizationId, me.organizationId)).orderBy(asc(kiosks.name))
|
||||||
|
return c.json({ kiosks: await Promise.all(rows.map((row) => toKiosk(db, row))) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/', requireRole('safety_manager'), validate('json', createKioskSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
await assertSite(db, me.organizationId, input.siteId)
|
||||||
|
const [row] = await db
|
||||||
|
.insert(kiosks)
|
||||||
|
.values({ ...input, organizationId: me.organizationId, token: randomBytes(24).toString('base64url'), createdById: me.id })
|
||||||
|
.returning()
|
||||||
|
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'create', changes: { name: row.name } })
|
||||||
|
return c.json({ kiosk: await toKiosk(db, row) }, 201)
|
||||||
|
})
|
||||||
|
|
||||||
|
.patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateKioskSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const [current] = await db
|
||||||
|
.select()
|
||||||
|
.from(kiosks)
|
||||||
|
.where(and(eq(kiosks.id, c.req.valid('param').id), eq(kiosks.organizationId, me.organizationId)))
|
||||||
|
if (!current) throw fail(404, 'Reporting link not found')
|
||||||
|
const { revoked, ...fields } = c.req.valid('json')
|
||||||
|
await assertSite(db, me.organizationId, fields.siteId)
|
||||||
|
const update = { ...fields, revokedAt: revoked === undefined ? undefined : revoked ? (current.revokedAt ?? new Date()) : null }
|
||||||
|
const changes = diff(current, update)
|
||||||
|
if (Object.keys(changes).length === 0) return c.json({ kiosk: await toKiosk(db, current) })
|
||||||
|
const [row] = await db.update(kiosks).set(update).where(eq(kiosks.id, current.id)).returning()
|
||||||
|
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'update', changes })
|
||||||
|
return c.json({ kiosk: await toKiosk(db, row) })
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints used with a reporting link's token instead of a session. The token travels in a custom
|
||||||
|
* header, which browsers never send cross-site without a CORS preflight, so these can't be forged
|
||||||
|
* from another site.
|
||||||
|
*/
|
||||||
|
export function kioskPublicRoutes() {
|
||||||
|
// Each link can file 30 reports per 10 minutes: plenty for a busy shop floor, a brake on abuse.
|
||||||
|
const limiter = new RateLimiter(30, 10 * 60 * 1000)
|
||||||
|
|
||||||
|
async function kioskFrom(c: Context<AppEnv>) {
|
||||||
|
const token = c.req.header(KIOSK_HEADER)
|
||||||
|
if (!token) throw fail(401, 'Missing reporting link token')
|
||||||
|
const [row] = await c
|
||||||
|
.get('db')
|
||||||
|
.select()
|
||||||
|
.from(kiosks)
|
||||||
|
.where(and(eq(kiosks.token, token), isNull(kiosks.revokedAt)))
|
||||||
|
if (!row) throw fail(401, 'This reporting link is no longer active. Ask your safety team for a new one.')
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Hono<AppEnv>()
|
||||||
|
.get('/config', async (c) => {
|
||||||
|
const kiosk = await kioskFrom(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, kiosk.organizationId))
|
||||||
|
const siteRows = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(and(eq(sites.organizationId, kiosk.organizationId), isNull(sites.archivedAt), kiosk.siteId ? eq(sites.id, kiosk.siteId) : undefined))
|
||||||
|
.orderBy(asc(sites.name))
|
||||||
|
const areaRows = siteRows.length
|
||||||
|
? await db
|
||||||
|
.select()
|
||||||
|
.from(areas)
|
||||||
|
.where(and(inArray(areas.siteId, siteRows.map((s) => s.id)), isNull(areas.archivedAt)))
|
||||||
|
.orderBy(asc(areas.name))
|
||||||
|
: []
|
||||||
|
const config: KioskConfig = {
|
||||||
|
organization,
|
||||||
|
kiosk: { name: kiosk.name },
|
||||||
|
sites: siteRows.map((site) => ({
|
||||||
|
id: site.id,
|
||||||
|
name: site.name,
|
||||||
|
address: null,
|
||||||
|
timezone: site.timezone,
|
||||||
|
archivedAt: null,
|
||||||
|
areas: areaRows
|
||||||
|
.filter((area) => area.siteId === site.id)
|
||||||
|
.map((area) => ({ id: area.id, siteId: area.siteId, parentId: area.parentId, name: area.name, kind: area.kind, archivedAt: null })),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
return c.json(config)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Multipart: a `report` field holding the JSON report, plus up to 5 `files`.
|
||||||
|
.post('/reports', async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes * MAX_FILES_PER_REPORT + 256 * 1024, onError: (c) => c.json({ error: 'Files are too large' }, 413) })(c, next), async (c) => {
|
||||||
|
const kiosk = await kioskFrom(c)
|
||||||
|
if (!limiter.attempt(kiosk.id)) throw fail(429, 'Too many reports from this link right now. Please try again in a few minutes.')
|
||||||
|
const db = c.get('db')
|
||||||
|
|
||||||
|
const body = await c.req.parseBody({ all: true })
|
||||||
|
let json: unknown
|
||||||
|
try {
|
||||||
|
json = JSON.parse(String(body.report ?? ''))
|
||||||
|
} catch {
|
||||||
|
throw fail(400, 'Missing report')
|
||||||
|
}
|
||||||
|
const parsed = kioskReportSchema.safeParse(json)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return c.json({ error: 'Invalid request', issues: parsed.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, 400)
|
||||||
|
}
|
||||||
|
if (kiosk.siteId && parsed.data.siteId !== kiosk.siteId) throw fail(400, 'This reporting link is for a different site')
|
||||||
|
const files = ([] as unknown[]).concat(body.files ?? []).filter((file) => file instanceof File)
|
||||||
|
if (files.length > MAX_FILES_PER_REPORT) throw fail(400, `Attach at most ${MAX_FILES_PER_REPORT} photos`)
|
||||||
|
|
||||||
|
const { reporterName, ...report } = parsed.data
|
||||||
|
const { id, number, created } = await fileReport(db, c.get('notifier'), {
|
||||||
|
...report,
|
||||||
|
organizationId: kiosk.organizationId,
|
||||||
|
reporter: null,
|
||||||
|
reporterName,
|
||||||
|
anonymous: !reporterName,
|
||||||
|
kioskId: kiosk.id,
|
||||||
|
})
|
||||||
|
await db.update(kiosks).set({ lastUsedAt: new Date() }).where(eq(kiosks.id, kiosk.id))
|
||||||
|
|
||||||
|
// Files are only taken with the first copy of a submission, so retries don't duplicate photos.
|
||||||
|
const failed: string[] = []
|
||||||
|
if (created) {
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const row = await saveAttachment({ db, storage: c.get('storage'), config: c.get('config') }, id, file, null)
|
||||||
|
await auditIncident(db, null, kiosk.organizationId, id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
|
||||||
|
} catch (error) {
|
||||||
|
failed.push(`${(file as File).name}: ${error instanceof Error ? error.message : 'upload failed'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only the reference goes back: people using a link can't read reports.
|
||||||
|
return c.json({ reference: incidentReference(number), failedFiles: failed }, created ? 201 : 200)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createTestContext } from '../test/helpers.ts'
|
||||||
|
|
||||||
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||||
|
let manager: Awaited<ReturnType<typeof ctx.createUser>>
|
||||||
|
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
|
||||||
|
let site: { id: string }
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
|
||||||
|
/** An incident earlier this year (Jan 15, local time) so it lands on this year's log. */
|
||||||
|
async function incident(title = 'Hand caught in press') {
|
||||||
|
const occurredAt = new Date(year, 0, 15, 10, 30).toISOString()
|
||||||
|
const res = await supervisor.post('/api/incidents', { title, type: 'injury', severity: 'high', occurredAt, siteId: site.id, locationDetail: 'Line 3' })
|
||||||
|
return res.body.incident.number as number
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordableAnswers = {
|
||||||
|
workRelated: true,
|
||||||
|
newCase: true,
|
||||||
|
jobTitle: 'Press operator',
|
||||||
|
natureOfInjury: 'Laceration',
|
||||||
|
bodyPart: 'left hand',
|
||||||
|
objectSubstance: 'Press die',
|
||||||
|
dateOfBirth: '1990-04-02',
|
||||||
|
dateOfHire: '2019-06-10',
|
||||||
|
activityBefore: 'Clearing a jam',
|
||||||
|
whatHappened: 'Hand slipped into the die area',
|
||||||
|
sex: 'M',
|
||||||
|
timeStartedWork: '07:00',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createTestContext()
|
||||||
|
site = await ctx.createSite('Main Plant')
|
||||||
|
manager = await ctx.createUser('safety_manager')
|
||||||
|
supervisor = await ctx.createUser('supervisor')
|
||||||
|
})
|
||||||
|
afterAll(() => ctx.close())
|
||||||
|
|
||||||
|
describe('injured people (OSHA cases)', () => {
|
||||||
|
it('keeps medical details to safety managers', async () => {
|
||||||
|
const number = await incident()
|
||||||
|
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Pat Doe' })
|
||||||
|
expect((await supervisor.get(`/api/osha/incidents/${number}/cases`)).status).toBe(403)
|
||||||
|
expect((await supervisor.post(`/api/osha/incidents/${number}/cases`, { name: 'X' })).status).toBe(403)
|
||||||
|
const detail = (await supervisor.get(`/api/incidents/${number}`)).body.incident
|
||||||
|
expect(detail).toMatchObject({ injuredCount: 1, can: { manageOsha: false } })
|
||||||
|
// The timeline says a person was added, without naming them
|
||||||
|
const timeline = (await supervisor.get(`/api/incidents/${number}/timeline`)).body.timeline
|
||||||
|
expect(JSON.stringify(timeline)).not.toContain('Pat Doe')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works out recordability from the answers, and re-checks on every change', async () => {
|
||||||
|
const number = await incident()
|
||||||
|
let res = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Sam Lee' })
|
||||||
|
expect(res.body.case).toMatchObject({ recordable: false, outcome: null, caseNumber: `INC-${String(number).padStart(4, '0')}` })
|
||||||
|
const id = res.body.case.id
|
||||||
|
|
||||||
|
res = await manager.patch(`/api/osha/cases/${id}`, { ...recordableAnswers })
|
||||||
|
expect(res.body.case).toMatchObject({ recordable: false }) // first aid only so far
|
||||||
|
|
||||||
|
res = await manager.patch(`/api/osha/cases/${id}`, { medicalTreatment: true })
|
||||||
|
expect(res.body.case).toMatchObject({ recordable: true, outcome: 'other' })
|
||||||
|
|
||||||
|
res = await manager.patch(`/api/osha/cases/${id}`, { daysAwayFromWork: true, daysAway: 4, restrictedOrTransfer: true, daysRestricted: 10 })
|
||||||
|
expect(res.body.case).toMatchObject({ outcome: 'days_away', daysAway: 4, daysRestricted: 10, missingFields: [] })
|
||||||
|
|
||||||
|
res = await manager.patch(`/api/osha/cases/${id}`, { workRelated: false })
|
||||||
|
expect(res.body.case.recordable).toBe(false)
|
||||||
|
await manager.patch(`/api/osha/cases/${id}`, { workRelated: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('numbers each person on an incident and enforces the 180-day cap', async () => {
|
||||||
|
const number = await incident()
|
||||||
|
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'First' })
|
||||||
|
const second = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Second' })
|
||||||
|
expect(second.body.case.caseNumber).toBe(`INC-${String(number).padStart(4, '0')}-2`)
|
||||||
|
const res = await manager.patch(`/api/osha/cases/${second.body.case.id}`, { daysAwayFromWork: true, daysAway: 150, restrictedOrTransfer: true, daysRestricted: 40 })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.body.error).toMatch(/180/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores day counts when the outcome does not apply', async () => {
|
||||||
|
const number = await incident()
|
||||||
|
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Counts', daysAway: 12 })
|
||||||
|
expect(created.body.case.daysAway).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('alerts safety managers when a case must be reported to OSHA, once', async () => {
|
||||||
|
const number = await incident('Finger amputation at shear')
|
||||||
|
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Alex Kim', ...recordableAnswers, amputation: true, medicalTreatment: true })
|
||||||
|
expect(created.body.case.severeReport).toMatchObject({ withinHours: 24, reasons: ['amputation'] })
|
||||||
|
await ctx.notifier.flush()
|
||||||
|
const alert = ctx.mailer.last(manager.user.email)
|
||||||
|
expect(alert?.subject).toMatch(/^Report to OSHA within 24 hours: INC-\d{4}$/)
|
||||||
|
expect(alert?.text).toContain('1-800-321-OSHA')
|
||||||
|
|
||||||
|
const before = ctx.mailer.sent.length
|
||||||
|
await manager.patch(`/api/osha/cases/${created.body.case.id}`, { reportedToOshaAt: new Date().toISOString(), lossOfEye: true })
|
||||||
|
await ctx.notifier.flush()
|
||||||
|
expect(ctx.mailer.sent.length).toBe(before)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OSHA log, summary and rates', () => {
|
||||||
|
let other: { id: string }
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
other = await ctx.createSite('Second Plant')
|
||||||
|
const occurredAt = new Date(year, 1, 3, 14, 5).toISOString()
|
||||||
|
const res = await supervisor.post('/api/incidents', { title: 'Needlestick', type: 'injury', severity: 'medium', occurredAt, siteId: other.id })
|
||||||
|
const number = res.body.incident.number
|
||||||
|
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Jordan Private', ...recordableAnswers, specialCase: 'needlestick', privacyCase: true, restrictedOrTransfer: true, daysRestricted: 3, caseType: 'other_illness' })
|
||||||
|
if (!created.body.case.recordable) throw new Error('setup: needlestick case should be recordable')
|
||||||
|
// Not recordable: should stay off the log
|
||||||
|
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'First Aid Only', ...recordableAnswers })
|
||||||
|
await manager.put(`/api/osha/sites/${other.id}/hours`, {
|
||||||
|
year,
|
||||||
|
months: Array.from({ length: 12 }, (_, i) => ({ month: i + 1, hours: 10_000, averageEmployees: i < 6 ? 50 : 70 })),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds the 300 Log with privacy cases hidden', async () => {
|
||||||
|
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
|
||||||
|
expect(log.rows).toHaveLength(1)
|
||||||
|
expect(log.rows[0]).toMatchObject({ employeeName: 'Privacy case', jobTitle: 'Press operator', outcome: 'restricted', daysRestricted: 3, caseType: 'other_illness' })
|
||||||
|
expect(log.rows[0].description).toBe('Laceration (left hand); Press die')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('totals the 300A, computes rates and runs the ITA checks', async () => {
|
||||||
|
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
|
||||||
|
expect(log.totals).toMatchObject({ deaths: 0, daysAwayCases: 0, restrictedCases: 1, otherCases: 0, daysRestricted: 3 })
|
||||||
|
expect(log).toMatchObject({ totalHoursWorked: 120_000, annualAverageEmployees: 60, maxEmployees: 70 })
|
||||||
|
expect(log.rates).toEqual({ trir: 1.67, dart: 1.67, ltir: 0 })
|
||||||
|
expect(log.checks.errors).toEqual([])
|
||||||
|
expect(log.missingEstablishmentFields).toEqual(['street address', 'city', 'state', 'ZIP code', 'NAICS code', 'EIN'])
|
||||||
|
|
||||||
|
const empty = (await manager.get(`/api/osha/log?siteId=${site.id}&year=${year}`)).body
|
||||||
|
expect(empty.checks.errors).toContain('Enter hours worked for each month.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates and normalises establishment details', async () => {
|
||||||
|
expect((await manager.patch(`/api/sites/${other.id}`, { naicsCode: '33211' })).status).toBe(400)
|
||||||
|
const res = await manager.patch(`/api/sites/${other.id}`, { streetAddress: '1 Mill Rd', city: 'Dayton', state: 'oh', zip: '45402-1234', naicsCode: '332119', ein: '12-3456789' })
|
||||||
|
expect(res.body.site).toMatchObject({ state: 'OH', zip: '454021234', ein: '123456789' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports the ITA 300A file in OSHA’s format', async () => {
|
||||||
|
const res = await manager.get(`/api/osha/ita/summary.csv?year=${year}`)
|
||||||
|
const lines = res.body.toString('utf8').trimEnd().split('\r\n')
|
||||||
|
expect(lines[0].charCodeAt(0)).not.toBe(0xfeff)
|
||||||
|
expect(lines[0]).toBe(
|
||||||
|
'establishment_name,ein_number,company_name,street_address,city,state,zip,naics_code,industry_description,size,establishment_type,year_filing_for,annual_average_employees,total_hours_worked,no_injuries_illnesses,total_deaths,total_dafw_cases,total_djtr_cases,total_other_cases,total_dafw_days,total_djtr_days,total_injuries,total_skin_disorders,total_respiratory_conditions,total_poisonings,total_hearing_loss,total_other_illnesses,change_reason',
|
||||||
|
)
|
||||||
|
const second = lines.find((line: string) => line.startsWith('Second Plant'))
|
||||||
|
expect(second).toBe(`Second Plant,123456789,Northstar Co.,1 Mill Rd,Dayton,OH,454021234,332119,,21,1,${year},60,120000,1,0,0,1,0,0,3,0,0,0,0,0,1,`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports ITA case data without names', async () => {
|
||||||
|
const res = await manager.get(`/api/osha/ita/cases.csv?year=${year}`)
|
||||||
|
const csv = res.body.toString('utf8')
|
||||||
|
expect(csv).not.toContain('Jordan Private')
|
||||||
|
expect(csv).not.toContain('First Aid Only')
|
||||||
|
const [header, ...rows] = csv.trimEnd().split('\r\n')
|
||||||
|
expect(header.split(',')).toHaveLength(24)
|
||||||
|
const row = rows.find((line: string) => line.startsWith('Second Plant'))!
|
||||||
|
expect(row).toContain(`02-03-${year}`) // mm-dd-yyyy
|
||||||
|
expect(row).toContain(',3,0,3,6,04-02-1990,06-10-2019,M,0,0,07:00,14:05,,') // outcome, days, type, dates, sex, treatment, times
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces a Form 301 for a case', async () => {
|
||||||
|
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
|
||||||
|
const form = (await manager.get(`/api/osha/cases/${log.rows[0].caseId}/301`)).body
|
||||||
|
expect(form).toMatchObject({ establishment: { name: 'Second Plant', city: 'Dayton' }, case: { name: 'Jordan Private', privacyCase: true }, incident: { title: 'Needlestick' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { and, asc, count, eq } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { Form301 } from '../../shared/api-types.ts'
|
||||||
|
import { incidentReference } from '../../shared/format.ts'
|
||||||
|
import { assessRecordability, caseTypeCode, outcomeCode, severeInjuryReport, itaSizeCode } from '../../shared/osha.ts'
|
||||||
|
import { createOshaCaseSchema, hoursWorkedSchema, updateOshaCaseSchema } from '../../shared/schemas.ts'
|
||||||
|
import { requireRole } from '../auth/middleware.ts'
|
||||||
|
import type { SessionUser } from '../auth/sessions.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { areas, hoursWorked, incidents, injuredPersons, sites } from '../db/schema.ts'
|
||||||
|
import { toCsv } from '../lib/csv.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
import { auditIncident, findIncident } from '../lib/incidents.ts'
|
||||||
|
import { activeSites, buildOshaYear, describeCase, establishmentOf, toOshaCase, yearRange } from '../lib/osha.ts'
|
||||||
|
|
||||||
|
const numberParam = z.object({ number: z.coerce.number().int().positive() })
|
||||||
|
const idParam = z.object({ id: z.uuid() })
|
||||||
|
const yearQuery = z.object({ year: z.coerce.number().int().min(2000).max(2100) })
|
||||||
|
|
||||||
|
type Person = typeof injuredPersons.$inferSelect
|
||||||
|
|
||||||
|
/** Days only count when the matching outcome applies; recordability is always derived, never sent. */
|
||||||
|
function derive(person: Omit<Person, 'recordable' | 'outcome'>) {
|
||||||
|
if (person.daysAway + person.daysRestricted > 180) throw fail(400, 'Stop counting once days away and restricted days reach 180 in total (1904.7(b)(3)(vii)).')
|
||||||
|
const assessment = assessRecordability(person)
|
||||||
|
return {
|
||||||
|
daysAway: person.daysAwayFromWork ? person.daysAway : 0,
|
||||||
|
daysRestricted: person.restrictedOrTransfer ? person.daysRestricted : 0,
|
||||||
|
recordable: assessment.status === 'recordable',
|
||||||
|
outcome: assessment.outcome,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCase(db: Db, user: SessionUser, id: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ person: injuredPersons, incident: incidents })
|
||||||
|
.from(injuredPersons)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||||
|
.where(and(eq(injuredPersons.id, id), eq(incidents.organizationId, user.organizationId)))
|
||||||
|
if (!row) throw fail(404, 'Case not found')
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMDY = (isoDate: string | null) => (isoDate ? `${isoDate.slice(5, 7)}-${isoDate.slice(8, 10)}-${isoDate.slice(0, 4)}` : '')
|
||||||
|
const localTime = (date: Date) => `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
const localDateMDY = (date: Date) => `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}-${date.getFullYear()}`
|
||||||
|
const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OSHA recordkeeping. Injury and medical details are limited to safety managers and admins
|
||||||
|
* (they hold the confidential records); everyone else only sees that a person was involved.
|
||||||
|
*/
|
||||||
|
export const oshaRoutes = new Hono<AppEnv>()
|
||||||
|
.get('/incidents/:number/cases', requireRole('safety_manager'), validate('param', numberParam), async (c) => {
|
||||||
|
const db = c.get('db')
|
||||||
|
const { incident } = await findIncident(db, currentUser(c), c.req.valid('param').number)
|
||||||
|
const people = await db.select().from(injuredPersons).where(eq(injuredPersons.incidentId, incident.id)).orderBy(asc(injuredPersons.createdAt))
|
||||||
|
return c.json({ cases: people.map((person) => toOshaCase(person, incident)) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/incidents/:number/cases', requireRole('safety_manager'), validate('param', numberParam), validate('json', createOshaCaseSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
const [{ existing }] = await db.select({ existing: count() }).from(injuredPersons).where(eq(injuredPersons.incidentId, incident.id))
|
||||||
|
const reference = incidentReference(incident.number)
|
||||||
|
|
||||||
|
const draft = {
|
||||||
|
employmentType: 'employee' as const,
|
||||||
|
caseType: 'injury' as const,
|
||||||
|
workRelated: null,
|
||||||
|
newCase: null,
|
||||||
|
died: false,
|
||||||
|
daysAwayFromWork: false,
|
||||||
|
daysAway: 0,
|
||||||
|
restrictedOrTransfer: false,
|
||||||
|
daysRestricted: 0,
|
||||||
|
medicalTreatment: false,
|
||||||
|
lossOfConsciousness: false,
|
||||||
|
significantDiagnosis: false,
|
||||||
|
specialCase: 'none' as const,
|
||||||
|
...input,
|
||||||
|
reportedToOshaAt: input.reportedToOshaAt ? new Date(input.reportedToOshaAt) : null,
|
||||||
|
}
|
||||||
|
const [person] = await db
|
||||||
|
.insert(injuredPersons)
|
||||||
|
.values({ ...draft, ...derive(draft as Person), incidentId: incident.id, caseNumber: existing ? `${reference}-${existing + 1}` : reference })
|
||||||
|
.returning()
|
||||||
|
// The timeline is visible beyond the safety team, so it carries no names or medical details.
|
||||||
|
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.add', { caseNumber: person.caseNumber, recordable: person.recordable })
|
||||||
|
if (severeInjuryReport(person) && !person.reportedToOshaAt) c.get('notifier').severeInjury(person.id)
|
||||||
|
return c.json({ case: toOshaCase(person, incident) }, 201)
|
||||||
|
})
|
||||||
|
|
||||||
|
.patch('/cases/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateOshaCaseSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
const merged = { ...person, ...input, reportedToOshaAt: input.reportedToOshaAt === undefined ? person.reportedToOshaAt : input.reportedToOshaAt ? new Date(input.reportedToOshaAt) : null }
|
||||||
|
const update = { ...merged, ...derive(merged) }
|
||||||
|
const [saved] = await db
|
||||||
|
.update(injuredPersons)
|
||||||
|
.set({ ...update, id: undefined, incidentId: undefined, createdAt: undefined, updatedAt: undefined })
|
||||||
|
.where(eq(injuredPersons.id, person.id))
|
||||||
|
.returning()
|
||||||
|
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.update', {
|
||||||
|
caseNumber: saved.caseNumber,
|
||||||
|
...(saved.recordable !== person.recordable ? { recordable: [person.recordable, saved.recordable] } : {}),
|
||||||
|
})
|
||||||
|
const becameSevere = severeInjuryReport(saved) && !severeInjuryReport(person)
|
||||||
|
if (becameSevere && !saved.reportedToOshaAt) c.get('notifier').severeInjury(saved.id)
|
||||||
|
return c.json({ case: toOshaCase(saved, incident) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.delete('/cases/:id', requireRole('safety_manager'), validate('param', idParam), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
|
||||||
|
await db.delete(injuredPersons).where(eq(injuredPersons.id, person.id))
|
||||||
|
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.remove', { caseNumber: person.caseNumber })
|
||||||
|
return c.body(null, 204)
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/cases/:id/301', requireRole('safety_manager'), validate('param', idParam), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
|
||||||
|
const [site] = await db.select().from(sites).where(eq(sites.id, incident.siteId))
|
||||||
|
const [area] = incident.areaId ? await db.select({ name: areas.name }).from(areas).where(eq(areas.id, incident.areaId)) : []
|
||||||
|
const year = await buildOshaYear(db, me.organizationId, site.id, incident.occurredAt.getFullYear())
|
||||||
|
const form: Form301 = {
|
||||||
|
case: toOshaCase(person, incident),
|
||||||
|
establishment: { name: site.name, companyName: year.companyName, ...establishmentOf(site) },
|
||||||
|
incident: {
|
||||||
|
reference: incidentReference(incident.number),
|
||||||
|
occurredAt: incident.occurredAt.toISOString(),
|
||||||
|
title: incident.title,
|
||||||
|
description: incident.description,
|
||||||
|
site: site.name,
|
||||||
|
area: area?.name ?? null,
|
||||||
|
locationDetail: incident.locationDetail,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return c.json(form)
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/log', requireRole('safety_manager'), validate('query', yearQuery.extend({ siteId: z.uuid() })), async (c) => {
|
||||||
|
const { siteId, year } = c.req.valid('query')
|
||||||
|
return c.json(await buildOshaYear(c.get('db'), currentUser(c).organizationId, siteId, year))
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/sites/:id/hours', requireRole('safety_manager'), validate('param', idParam), validate('query', yearQuery), async (c) => {
|
||||||
|
const year = await buildOshaYear(c.get('db'), currentUser(c).organizationId, c.req.valid('param').id, c.req.valid('query').year)
|
||||||
|
return c.json({ months: year.months })
|
||||||
|
})
|
||||||
|
|
||||||
|
.put('/sites/:id/hours', requireRole('safety_manager'), validate('param', idParam), validate('json', hoursWorkedSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const siteId = c.req.valid('param').id
|
||||||
|
const { year, months } = c.req.valid('json')
|
||||||
|
await buildOshaYear(db, me.organizationId, siteId, year) // 404 unless the site is ours
|
||||||
|
for (const { month, hours, averageEmployees } of months) {
|
||||||
|
const monthDate = `${year}-${String(month).padStart(2, '0')}-01`
|
||||||
|
if (hours === null && averageEmployees === null) {
|
||||||
|
await db.delete(hoursWorked).where(and(eq(hoursWorked.siteId, siteId), eq(hoursWorked.month, monthDate)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.insert(hoursWorked)
|
||||||
|
.values({ siteId, month: monthDate, hours: String(hours ?? 0), averageEmployees })
|
||||||
|
.onConflictDoUpdate({ target: [hoursWorked.siteId, hoursWorked.month], set: { hours: String(hours ?? 0), averageEmployees } })
|
||||||
|
}
|
||||||
|
const updated = await buildOshaYear(db, me.organizationId, siteId, year)
|
||||||
|
return c.json({ months: updated.months })
|
||||||
|
})
|
||||||
|
|
||||||
|
// OSHA Injury Tracking Application uploads. Formats follow OSHA's published CSV specifications.
|
||||||
|
.get('/ita/summary.csv', requireRole('safety_manager'), validate('query', yearQuery), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { year } = c.req.valid('query')
|
||||||
|
const rows = []
|
||||||
|
for (const site of await activeSites(db, me.organizationId)) {
|
||||||
|
const y = await buildOshaYear(db, me.organizationId, site.id, year)
|
||||||
|
const t = y.totals
|
||||||
|
rows.push([
|
||||||
|
site.name,
|
||||||
|
site.ein,
|
||||||
|
y.companyName,
|
||||||
|
site.streetAddress,
|
||||||
|
site.city,
|
||||||
|
site.state,
|
||||||
|
site.zip,
|
||||||
|
site.naicsCode,
|
||||||
|
site.industryDescription,
|
||||||
|
itaSizeCode(y.maxEmployees),
|
||||||
|
site.establishmentType,
|
||||||
|
year,
|
||||||
|
y.annualAverageEmployees,
|
||||||
|
y.totalHoursWorked,
|
||||||
|
y.rows.length ? 1 : 2,
|
||||||
|
t.deaths,
|
||||||
|
t.daysAwayCases,
|
||||||
|
t.restrictedCases,
|
||||||
|
t.otherCases,
|
||||||
|
t.daysAway,
|
||||||
|
t.daysRestricted,
|
||||||
|
t.byType.injury,
|
||||||
|
t.byType.skin_disorder,
|
||||||
|
t.byType.respiratory,
|
||||||
|
t.byType.poisoning,
|
||||||
|
t.byType.hearing_loss,
|
||||||
|
t.byType.other_illness,
|
||||||
|
'',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
const csv = toCsv(
|
||||||
|
[
|
||||||
|
'establishment_name', 'ein_number', 'company_name', 'street_address', 'city', 'state', 'zip', 'naics_code', 'industry_description', 'size',
|
||||||
|
'establishment_type', 'year_filing_for', 'annual_average_employees', 'total_hours_worked', 'no_injuries_illnesses', 'total_deaths',
|
||||||
|
'total_dafw_cases', 'total_djtr_cases', 'total_other_cases', 'total_dafw_days', 'total_djtr_days', 'total_injuries', 'total_skin_disorders',
|
||||||
|
'total_respiratory_conditions', 'total_poisonings', 'total_hearing_loss', 'total_other_illnesses', 'change_reason',
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
{ bom: false },
|
||||||
|
)
|
||||||
|
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="osha-ita-300a-${year}.csv"` })
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/ita/cases.csv', requireRole('safety_manager'), validate('query', yearQuery), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { year } = c.req.valid('query')
|
||||||
|
const [from, to] = yearRange(year)
|
||||||
|
const rows = []
|
||||||
|
for (const site of await activeSites(db, me.organizationId)) {
|
||||||
|
const y = await buildOshaYear(db, me.organizationId, site.id, year)
|
||||||
|
for (const row of y.rows) {
|
||||||
|
const [{ person, incident }] = await db
|
||||||
|
.select({ person: injuredPersons, incident: incidents })
|
||||||
|
.from(injuredPersons)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||||
|
.where(eq(injuredPersons.id, row.caseId))
|
||||||
|
if (incident.occurredAt < from || incident.occurredAt >= to) continue
|
||||||
|
// No names or other personal identifiers: OSHA publishes this data.
|
||||||
|
rows.push([
|
||||||
|
site.name,
|
||||||
|
year,
|
||||||
|
row.caseNumber,
|
||||||
|
clip(row.jobTitle, 255),
|
||||||
|
localDateMDY(incident.occurredAt),
|
||||||
|
clip(row.location, 255),
|
||||||
|
clip(describeCase(person), 255),
|
||||||
|
outcomeCode[row.outcome],
|
||||||
|
row.daysAway,
|
||||||
|
row.daysRestricted,
|
||||||
|
caseTypeCode[row.caseType],
|
||||||
|
dateMDY(person.dateOfBirth),
|
||||||
|
dateMDY(person.dateOfHire),
|
||||||
|
person.sex ?? '',
|
||||||
|
person.treatedInEmergencyRoom ? 1 : 0,
|
||||||
|
person.inpatientHospitalization ? 1 : 0,
|
||||||
|
person.timeStartedWork ?? '',
|
||||||
|
localTime(incident.occurredAt),
|
||||||
|
'',
|
||||||
|
person.activityBefore ?? '',
|
||||||
|
person.whatHappened ?? incident.description ?? '',
|
||||||
|
person.natureOfInjury ?? '',
|
||||||
|
person.objectSubstance ?? '',
|
||||||
|
dateMDY(person.dateOfDeath),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const csv = toCsv(
|
||||||
|
[
|
||||||
|
'establishment_name', 'year_of_filing', 'case_number', 'job_title', 'date_of_incident', 'incident_location', 'incident_description',
|
||||||
|
'incident_outcome', 'dafw_num_away', 'djtr_num_tr', 'type_of_incident', 'date_of_birth', 'date_of_hire', 'sex', 'treatment_facility_type',
|
||||||
|
'treatment_in_patient', 'time_started_work', 'time_of_incident', 'time_unknown', 'nar_before_incident', 'nar_what_happened',
|
||||||
|
'nar_injury_illness', 'nar_object_substance', 'date_of_death',
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
{ bom: false },
|
||||||
|
)
|
||||||
|
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="osha-ita-cases-${year}.csv"` })
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { updateRootCauseSchema } from '../../shared/schemas.ts'
|
||||||
|
import { requireRole } from '../auth/middleware.ts'
|
||||||
|
import type { SessionUser } from '../auth/sessions.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { incidents, investigations, rootCauses } from '../db/schema.ts'
|
||||||
|
import { diff } from '../lib/audit.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
import { auditIncident, loadDetail } from '../lib/incidents.ts'
|
||||||
|
|
||||||
|
const idParam = z.object({ id: z.uuid() })
|
||||||
|
|
||||||
|
async function findRootCause(db: Db, user: SessionUser, id: string) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ cause: rootCauses, incidentId: incidents.id, number: incidents.number })
|
||||||
|
.from(rootCauses)
|
||||||
|
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||||
|
.innerJoin(incidents, eq(incidents.id, investigations.incidentId))
|
||||||
|
.where(and(eq(rootCauses.id, id), eq(incidents.organizationId, user.organizationId)))
|
||||||
|
if (!row) throw fail(404, 'Root cause not found')
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rootCauseRoutes = new Hono<AppEnv>()
|
||||||
|
.patch('/:id', requireRole('supervisor'), validate('param', idParam), validate('json', updateRootCauseSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { cause, incidentId, number } = await findRootCause(db, me, c.req.valid('param').id)
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
const changes = diff(cause, input)
|
||||||
|
if (Object.keys(changes).length > 0) {
|
||||||
|
await db.update(rootCauses).set(input).where(eq(rootCauses.id, cause.id))
|
||||||
|
await auditIncident(db, me, me.organizationId, incidentId, 'root_cause.update', changes)
|
||||||
|
}
|
||||||
|
return c.json({ incident: await loadDetail(db, me, number) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.delete('/:id', requireRole('supervisor'), validate('param', idParam), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const { cause, incidentId, number } = await findRootCause(db, me, c.req.valid('param').id)
|
||||||
|
await db.delete(rootCauses).where(eq(rootCauses.id, cause.id))
|
||||||
|
await auditIncident(db, me, me.organizationId, incidentId, 'root_cause.remove', { category: cause.category, description: cause.description })
|
||||||
|
return c.json({ incident: await loadDetail(db, me, number) })
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { setupSchema } from '../../shared/schemas.ts'
|
||||||
|
import { startSession } from '../auth/middleware.ts'
|
||||||
|
import { hashPassword } from '../auth/password.ts'
|
||||||
|
import type { Db } from '../db/client.ts'
|
||||||
|
import { organizations, users } from '../db/schema.ts'
|
||||||
|
import { audit } from '../lib/audit.ts'
|
||||||
|
import { validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
import { publicUser } from './users.ts'
|
||||||
|
|
||||||
|
const SETUP_LOCK = 717_001
|
||||||
|
|
||||||
|
async function hasUsers(db: Db) {
|
||||||
|
const rows = await db.select({ id: users.id }).from(users).limit(1)
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First-run setup: creates the organization and its first admin. Only works while there are no users. */
|
||||||
|
export const setupRoutes = new Hono<AppEnv>()
|
||||||
|
.get('/', async (c) => c.json({ needsSetup: !(await hasUsers(c.get('db'))) }))
|
||||||
|
|
||||||
|
.post('/', validate('json', setupSchema), async (c) => {
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
const passwordHash = await hashPassword(input.password)
|
||||||
|
|
||||||
|
const user = await c.get('db').transaction(async (tx) => {
|
||||||
|
await tx.execute(sql`select pg_advisory_xact_lock(${SETUP_LOCK})`)
|
||||||
|
if (await hasUsers(tx)) return null
|
||||||
|
const [organization] = await tx.insert(organizations).values({ name: input.organizationName }).returning()
|
||||||
|
const [admin] = await tx
|
||||||
|
.insert(users)
|
||||||
|
.values({ organizationId: organization.id, email: input.email, name: input.name, role: 'admin', passwordHash })
|
||||||
|
.returning(publicUser)
|
||||||
|
await audit(tx, { organizationId: organization.id, actorId: admin.id, entityType: 'organization', entityId: organization.id, action: 'setup' })
|
||||||
|
return admin
|
||||||
|
})
|
||||||
|
if (!user) return c.json({ error: 'Setup has already been completed' }, 409)
|
||||||
|
|
||||||
|
await startSession(c, user.id)
|
||||||
|
return c.json({ user }, 201)
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createTestContext } from '../test/helpers.ts'
|
||||||
|
|
||||||
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createTestContext()
|
||||||
|
})
|
||||||
|
afterAll(() => ctx.close())
|
||||||
|
|
||||||
|
describe('sites and areas', () => {
|
||||||
|
it('builds a location tree', async () => {
|
||||||
|
const site = await ctx.createSite('North Campus')
|
||||||
|
const building = await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Warehouse 04', kind: 'building' })
|
||||||
|
expect(building.status).toBe(201)
|
||||||
|
const bay = await ctx.admin.post(`/api/sites/${site.id}/areas`, { name: 'Loading bay C', parentId: building.body.area.id })
|
||||||
|
expect(bay.body.area).toMatchObject({ kind: 'area', parentId: building.body.area.id })
|
||||||
|
|
||||||
|
const list = await ctx.admin.get('/api/sites')
|
||||||
|
const north = list.body.sites.find((s: { name: string }) => s.name === 'North Campus')
|
||||||
|
expect(north.areas.map((a: { name: string }) => a.name)).toEqual(['Loading bay C', 'Warehouse 04'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a parent area from another site', async () => {
|
||||||
|
const a = await ctx.createSite('Site A')
|
||||||
|
const b = await ctx.createSite('Site B')
|
||||||
|
const areaInA = await ctx.admin.post(`/api/sites/${a.id}/areas`, { name: 'Line 1' })
|
||||||
|
const res = await ctx.admin.post(`/api/sites/${b.id}/areas`, { name: 'Line 2', parentId: areaInA.body.area.id })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects duplicate site names', async () => {
|
||||||
|
await ctx.createSite('Duplicate')
|
||||||
|
expect((await ctx.admin.post('/api/sites', { name: 'Duplicate' })).status).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides archived sites unless asked', async () => {
|
||||||
|
const site = await ctx.createSite('Old Plant')
|
||||||
|
await ctx.admin.patch(`/api/sites/${site.id}`, { archived: true })
|
||||||
|
const names = (await ctx.admin.get('/api/sites')).body.sites.map((s: { name: string }) => s.name)
|
||||||
|
expect(names).not.toContain('Old Plant')
|
||||||
|
const all = (await ctx.admin.get('/api/sites?includeArchived=true')).body.sites.map((s: { name: string }) => s.name)
|
||||||
|
expect(all).toContain('Old Plant')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets everyone read but only safety managers change locations', async () => {
|
||||||
|
const reporter = await ctx.createUser('reporter')
|
||||||
|
const supervisor = await ctx.createUser('supervisor')
|
||||||
|
expect((await reporter.get('/api/sites')).status).toBe(200)
|
||||||
|
expect((await supervisor.post('/api/sites', { name: 'Nope' })).status).toBe(403)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { createAreaSchema, createSiteSchema, updateAreaSchema, updateSiteSchema } from '../../shared/schemas.ts'
|
||||||
|
import { requireAuth, requireRole } from '../auth/middleware.ts'
|
||||||
|
import { isUniqueViolation, type Db } from '../db/client.ts'
|
||||||
|
import { areas, sites } from '../db/schema.ts'
|
||||||
|
import { audit, diff } from '../lib/audit.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
|
||||||
|
const idParam = z.object({ id: z.uuid() })
|
||||||
|
const listQuery = z.object({ includeArchived: z.enum(['true', 'false']).optional() })
|
||||||
|
|
||||||
|
async function findSite(db: Db, organizationId: string, id: string) {
|
||||||
|
const [site] = await db.select().from(sites).where(and(eq(sites.id, id), eq(sites.organizationId, organizationId)))
|
||||||
|
if (!site) throw fail(404, 'Site not found')
|
||||||
|
return site
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivedAt = (archived: boolean | undefined) => (archived === undefined ? undefined : archived ? new Date() : null)
|
||||||
|
|
||||||
|
export const siteRoutes = new Hono<AppEnv>()
|
||||||
|
.get('/', requireAuth, validate('query', listQuery), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const includeArchived = c.req.valid('query').includeArchived === 'true'
|
||||||
|
const db = c.get('db')
|
||||||
|
const siteRows = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(and(eq(sites.organizationId, me.organizationId), includeArchived ? undefined : isNull(sites.archivedAt)))
|
||||||
|
.orderBy(asc(sites.name))
|
||||||
|
const areaRows = siteRows.length
|
||||||
|
? await db
|
||||||
|
.select()
|
||||||
|
.from(areas)
|
||||||
|
.where(and(inArray(areas.siteId, siteRows.map((site) => site.id)), includeArchived ? undefined : isNull(areas.archivedAt)))
|
||||||
|
.orderBy(asc(areas.name))
|
||||||
|
: []
|
||||||
|
return c.json({ sites: siteRows.map((site) => ({ ...site, areas: areaRows.filter((area) => area.siteId === site.id) })) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/', requireRole('safety_manager'), validate('json', createSiteSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
try {
|
||||||
|
const [site] = await db
|
||||||
|
.insert(sites)
|
||||||
|
.values({ ...c.req.valid('json'), organizationId: me.organizationId })
|
||||||
|
.returning()
|
||||||
|
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'site', entityId: site.id, action: 'create', changes: { name: site.name } })
|
||||||
|
return c.json({ site: { ...site, areas: [] } }, 201)
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw fail(409, 'A site with that name already exists')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateSiteSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const site = await findSite(db, me.organizationId, c.req.valid('param').id)
|
||||||
|
const { archived, ...fields } = c.req.valid('json')
|
||||||
|
const update = { ...fields, archivedAt: archivedAt(archived) }
|
||||||
|
const changes = diff(site, update)
|
||||||
|
if (Object.keys(changes).length === 0) return c.json({ site })
|
||||||
|
try {
|
||||||
|
const [updated] = await db.update(sites).set(update).where(eq(sites.id, site.id)).returning()
|
||||||
|
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'site', entityId: site.id, action: 'update', changes })
|
||||||
|
return c.json({ site: updated })
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw fail(409, 'A site with that name already exists')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/:id/areas', requireRole('safety_manager'), validate('param', idParam), validate('json', createAreaSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const site = await findSite(db, me.organizationId, c.req.valid('param').id)
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
if (input.parentId) {
|
||||||
|
const [parent] = await db.select({ id: areas.id }).from(areas).where(and(eq(areas.id, input.parentId), eq(areas.siteId, site.id)))
|
||||||
|
if (!parent) throw fail(400, 'Parent area must belong to the same site')
|
||||||
|
}
|
||||||
|
const [area] = await db
|
||||||
|
.insert(areas)
|
||||||
|
.values({ ...input, siteId: site.id })
|
||||||
|
.returning()
|
||||||
|
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'area', entityId: area.id, action: 'create', changes: { name: area.name, siteId: site.id } })
|
||||||
|
return c.json({ area }, 201)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const areaRoutes = new Hono<AppEnv>().patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateAreaSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const [row] = await db
|
||||||
|
.select({ area: areas })
|
||||||
|
.from(areas)
|
||||||
|
.innerJoin(sites, eq(sites.id, areas.siteId))
|
||||||
|
.where(and(eq(areas.id, c.req.valid('param').id), eq(sites.organizationId, me.organizationId)))
|
||||||
|
if (!row) throw fail(404, 'Area not found')
|
||||||
|
const { archived, ...fields } = c.req.valid('json')
|
||||||
|
const update = { ...fields, archivedAt: archivedAt(archived) }
|
||||||
|
const changes = diff(row.area, update)
|
||||||
|
if (Object.keys(changes).length === 0) return c.json({ area: row.area })
|
||||||
|
const [area] = await db.update(areas).set(update).where(eq(areas.id, row.area.id)).returning()
|
||||||
|
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'area', entityId: area.id, action: 'update', changes })
|
||||||
|
return c.json({ area })
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts'
|
||||||
|
|
||||||
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createTestContext()
|
||||||
|
})
|
||||||
|
afterAll(() => ctx.close())
|
||||||
|
|
||||||
|
describe('stats', () => {
|
||||||
|
it('summarises the dashboard from real data', async () => {
|
||||||
|
const north = await ctx.createSite('North')
|
||||||
|
await ctx.createSite('South')
|
||||||
|
const supervisor = await ctx.createUser('supervisor')
|
||||||
|
const report = (title: string, severity = 'medium', type = 'hazard') =>
|
||||||
|
supervisor.post('/api/incidents', { title, type, severity, occurredAt: hoursAgo(3), siteId: north.id }).then((r) => r.body.incident.number as number)
|
||||||
|
|
||||||
|
const a = await report('A', 'low')
|
||||||
|
await report('B', 'high', 'injury')
|
||||||
|
await supervisor.post(`/api/incidents/${a}/transitions`, { to: 'triage' })
|
||||||
|
await supervisor.post(`/api/incidents/${a}/transitions`, { to: 'closed' })
|
||||||
|
const c = await report('C')
|
||||||
|
await supervisor.post(`/api/incidents/${c}/actions`, { title: 'Overdue', assigneeId: supervisor.user.id, dueDate: daysFromToday(-1) })
|
||||||
|
await supervisor.post(`/api/incidents/${c}/actions`, { title: 'This week', assigneeId: supervisor.user.id, dueDate: daysFromToday(3) })
|
||||||
|
await supervisor.post(`/api/incidents/${c}/root-causes`, { category: 'equipment', description: 'Worn' })
|
||||||
|
|
||||||
|
const overview = (await supervisor.get('/api/stats/overview')).body
|
||||||
|
expect(overview).toMatchObject({ openIncidents: 2, reportedLast7Days: 3, actionsDueThisWeek: 1, overdueActions: 1, actionProgress: { total: 2, completed: 0 } })
|
||||||
|
expect(overview.byStatus).toMatchObject({ reported: 2, closed: 1 })
|
||||||
|
expect(overview.averageCloseDays).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(overview.weeklyTrend).toHaveLength(12)
|
||||||
|
expect(overview.weeklyTrend.at(-1).count).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(overview.bySite).toEqual([
|
||||||
|
{ siteId: north.id, name: 'North', count: 3 },
|
||||||
|
{ siteId: expect.any(String), name: 'South', count: 0 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const to = new Date().toISOString().slice(0, 10)
|
||||||
|
const from = new Date(Date.now() - 29 * 86_400_000).toISOString().slice(0, 10)
|
||||||
|
const analytics = (await supervisor.get(`/api/stats/analytics?from=${from}&to=${to}`)).body
|
||||||
|
expect(analytics.kpis.reports.value).toBe(3)
|
||||||
|
expect(analytics.byRootCause).toEqual([{ category: 'equipment', count: 1 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("scopes a reporter's numbers to what they can see", async () => {
|
||||||
|
const reporter = await ctx.createUser('reporter')
|
||||||
|
expect((await reporter.get('/api/stats/overview')).body.openIncidents).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { and, asc, count, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import type { OverviewStats } from '../../shared/api-types.ts'
|
||||||
|
import { incidentStatuses, type IncidentStatus } from '../../shared/enums.ts'
|
||||||
|
import { requireAuth } from '../auth/middleware.ts'
|
||||||
|
import { correctiveActions, incidents, sites } from '../db/schema.ts'
|
||||||
|
import { addDays, localDate } from '../lib/dates.ts'
|
||||||
|
import { computeAnalytics } from '../lib/analytics.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
import { analyticsQuery } from '../../shared/schemas.ts'
|
||||||
|
import { visibleTo } from '../lib/incidents.ts'
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000
|
||||||
|
const OPEN_ACTION = ['open', 'in_progress'] as const
|
||||||
|
|
||||||
|
/** Monday 00:00 UTC of the week containing `date` (matches Postgres `date_trunc('week', …)` in UTC). */
|
||||||
|
function weekStart(date: Date): Date {
|
||||||
|
const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
||||||
|
start.setUTCDate(start.getUTCDate() - ((start.getUTCDay() + 6) % 7))
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statsRoutes = new Hono<AppEnv>()
|
||||||
|
.get('/overview', requireAuth, async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const visible = visibleTo(db, me)
|
||||||
|
const now = new Date()
|
||||||
|
const today = localDate(now)
|
||||||
|
|
||||||
|
const statusRows = await db.select({ status: incidents.status, n: count() }).from(incidents).where(visible).groupBy(incidents.status)
|
||||||
|
const byStatus = Object.fromEntries(incidentStatuses.map((status) => [status, 0])) as Record<IncidentStatus, number>
|
||||||
|
for (const row of statusRows) byStatus[row.status] = row.n
|
||||||
|
const openIncidents = Object.entries(byStatus).reduce((sum, [status, n]) => (status === 'closed' ? sum : sum + n), 0)
|
||||||
|
|
||||||
|
const [{ recent }] = await db
|
||||||
|
.select({ recent: count() })
|
||||||
|
.from(incidents)
|
||||||
|
.where(and(visible, gte(incidents.createdAt, new Date(now.getTime() - 7 * DAY_MS))))
|
||||||
|
|
||||||
|
const actionsBase = db.select({ n: count() }).from(correctiveActions).innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||||
|
const [{ n: dueThisWeek }] = await actionsBase.where(
|
||||||
|
and(visible, inArray(correctiveActions.status, OPEN_ACTION), gte(correctiveActions.dueDate, today), sql`${correctiveActions.dueDate} <= ${addDays(today, 7)}`),
|
||||||
|
)
|
||||||
|
const [{ n: overdue }] = await db
|
||||||
|
.select({ n: count() })
|
||||||
|
.from(correctiveActions)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||||
|
.where(and(visible, inArray(correctiveActions.status, OPEN_ACTION), lt(correctiveActions.dueDate, today)))
|
||||||
|
const progressRows = await db
|
||||||
|
.select({ status: correctiveActions.status, n: count() })
|
||||||
|
.from(correctiveActions)
|
||||||
|
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||||
|
.where(and(visible, ne(incidents.status, 'closed'), ne(correctiveActions.status, 'cancelled')))
|
||||||
|
.groupBy(correctiveActions.status)
|
||||||
|
const actionProgress = {
|
||||||
|
total: progressRows.reduce((sum, row) => sum + row.n, 0),
|
||||||
|
completed: progressRows.filter((row) => row.status === 'done' || row.status === 'verified').reduce((sum, row) => sum + row.n, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ avgDays }] = await db
|
||||||
|
.select({ avgDays: sql<string | null>`avg(extract(epoch from ${incidents.closedAt} - ${incidents.createdAt})) / 86400` })
|
||||||
|
.from(incidents)
|
||||||
|
.where(and(visible, isNotNull(incidents.closedAt), gte(incidents.closedAt, new Date(now.getTime() - 90 * DAY_MS))))
|
||||||
|
|
||||||
|
const firstWeek = weekStart(new Date(now.getTime() - 11 * 7 * DAY_MS))
|
||||||
|
const weekExpr = sql<string>`to_char(date_trunc('week', ${incidents.occurredAt} at time zone 'UTC'), 'YYYY-MM-DD')`
|
||||||
|
const weekRows = await db
|
||||||
|
.select({ week: weekExpr, n: count() })
|
||||||
|
.from(incidents)
|
||||||
|
.where(and(visible, gte(incidents.occurredAt, firstWeek)))
|
||||||
|
.groupBy(weekExpr)
|
||||||
|
const weeklyTrend = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const start = new Date(firstWeek.getTime() + i * 7 * DAY_MS).toISOString().slice(0, 10)
|
||||||
|
return { weekStart: start, count: weekRows.find((row) => row.week === start)?.n ?? 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const yearStart = new Date(now.getFullYear(), 0, 1)
|
||||||
|
const siteRows = await db
|
||||||
|
.select({ siteId: sites.id, name: sites.name, n: count(incidents.id) })
|
||||||
|
.from(sites)
|
||||||
|
.leftJoin(incidents, and(eq(incidents.siteId, sites.id), gte(incidents.occurredAt, yearStart), visible))
|
||||||
|
.where(and(eq(sites.organizationId, me.organizationId), isNull(sites.archivedAt)))
|
||||||
|
.groupBy(sites.id, sites.name)
|
||||||
|
.orderBy(desc(count(incidents.id)), asc(sites.name))
|
||||||
|
|
||||||
|
const stats: OverviewStats = {
|
||||||
|
openIncidents,
|
||||||
|
reportedLast7Days: recent,
|
||||||
|
actionsDueThisWeek: dueThisWeek,
|
||||||
|
overdueActions: overdue,
|
||||||
|
averageCloseDays: avgDays === null ? null : Math.round(Number(avgDays) * 10) / 10,
|
||||||
|
byStatus,
|
||||||
|
actionProgress,
|
||||||
|
weeklyTrend,
|
||||||
|
bySite: siteRows.map((row) => ({ siteId: row.siteId, name: row.name, count: row.n })),
|
||||||
|
}
|
||||||
|
return c.json(stats)
|
||||||
|
})
|
||||||
|
|
||||||
|
.get('/analytics', requireAuth, validate('query', analyticsQuery), async (c) => {
|
||||||
|
const filters = c.req.valid('query')
|
||||||
|
if (filters.from > filters.to) throw fail(400, 'The start date must be before the end date')
|
||||||
|
const days = (Date.parse(filters.to) - Date.parse(filters.from)) / 86_400_000
|
||||||
|
if (days > 3 * 366) throw fail(400, 'Choose a range of up to three years')
|
||||||
|
return c.json(await computeAnalytics(c.get('db'), currentUser(c), filters))
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { createTestContext } from '../test/helpers.ts'
|
||||||
|
|
||||||
|
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await createTestContext()
|
||||||
|
})
|
||||||
|
afterAll(() => ctx.close())
|
||||||
|
|
||||||
|
describe('users', () => {
|
||||||
|
it('lets admins create users and rejects duplicate emails', async () => {
|
||||||
|
const body = { name: 'Sam Rivera', email: 'Sam@Example.com', role: 'supervisor', password: 'a sufficiently long password' }
|
||||||
|
const created = await ctx.admin.post('/api/users', body)
|
||||||
|
expect(created.status).toBe(201)
|
||||||
|
expect(created.body.user).toMatchObject({ email: 'sam@example.com', role: 'supervisor', active: true })
|
||||||
|
expect(created.body.user.passwordHash).toBeUndefined()
|
||||||
|
|
||||||
|
expect((await ctx.admin.post('/api/users', body)).status).toBe(409)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('validates input', async () => {
|
||||||
|
const res = await ctx.admin.post('/api/users', { name: '', email: 'not-an-email', role: 'boss', password: 'short' })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.body.issues.map((issue: { path: string }) => issue.path).sort()).toEqual(['email', 'name', 'password', 'role'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restricts listing to safety managers and creating to admins', async () => {
|
||||||
|
const supervisor = await ctx.createUser('supervisor')
|
||||||
|
const manager = await ctx.createUser('safety_manager')
|
||||||
|
expect((await supervisor.get('/api/users')).status).toBe(403)
|
||||||
|
expect((await manager.get('/api/users')).status).toBe(200)
|
||||||
|
expect((await manager.post('/api/users', { name: 'X', email: 'x@example.com', role: 'admin', password: 'a sufficiently long password' })).status).toBe(403)
|
||||||
|
expect((await ctx.anonymous().get('/api/users')).status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 404 for unknown or malformed ids', async () => {
|
||||||
|
expect((await ctx.admin.patch('/api/users/00000000-0000-4000-8000-000000000000', { name: 'Nobody' })).status).toBe(404)
|
||||||
|
expect((await ctx.admin.patch('/api/users/not-a-uuid', { name: 'Nobody' })).status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps at least one active admin', async () => {
|
||||||
|
const me = await ctx.admin.get('/api/auth/me')
|
||||||
|
const adminId = me.body.user.id
|
||||||
|
expect((await ctx.admin.patch(`/api/users/${adminId}`, { role: 'reporter' })).status).toBe(409)
|
||||||
|
expect((await ctx.admin.patch(`/api/users/${adminId}`, { active: false })).status).toBe(400)
|
||||||
|
|
||||||
|
const second = await ctx.createUser('admin', 'second-admin@example.com')
|
||||||
|
const demoted = await second.patch(`/api/users/${adminId}`, { role: 'safety_manager' })
|
||||||
|
expect(demoted.status).toBe(200)
|
||||||
|
expect(demoted.body.user.role).toBe('safety_manager')
|
||||||
|
// The demoted user stays signed in but loses admin rights immediately
|
||||||
|
expect((await ctx.admin.get('/api/auth/me')).status).toBe(200)
|
||||||
|
expect((await ctx.admin.post('/api/users', { name: 'Y', email: 'y@example.com', role: 'reporter', password: 'a sufficiently long password' })).status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { and, asc, eq, ne } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { createUserSchema, updateUserSchema } from '../../shared/schemas.ts'
|
||||||
|
import { requireRole } from '../auth/middleware.ts'
|
||||||
|
import { createAuthToken } from '../auth/tokens.ts'
|
||||||
|
import { hashPassword } from '../auth/password.ts'
|
||||||
|
import { deleteUserSessions } from '../auth/sessions.ts'
|
||||||
|
import { isUniqueViolation } from '../db/client.ts'
|
||||||
|
import { organizations, users } from '../db/schema.ts'
|
||||||
|
import { audit, diff } from '../lib/audit.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
|
||||||
|
/** Columns safe to return to clients (never the password hash). */
|
||||||
|
export const publicUser = {
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
name: users.name,
|
||||||
|
role: users.role,
|
||||||
|
active: users.active,
|
||||||
|
lastLoginAt: users.lastLoginAt,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
const idParam = z.object({ id: z.uuid() })
|
||||||
|
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
export const userRoutes = new Hono<AppEnv>()
|
||||||
|
.get('/', requireRole('safety_manager'), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const rows = await c.get('db').select(publicUser).from(users).where(eq(users.organizationId, me.organizationId)).orderBy(asc(users.name))
|
||||||
|
return c.json({ users: rows })
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Active people who can lead investigations or be assigned actions. */
|
||||||
|
.get('/assignable', requireRole('supervisor'), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const rows = await c
|
||||||
|
.get('db')
|
||||||
|
.select({ id: users.id, name: users.name, role: users.role })
|
||||||
|
.from(users)
|
||||||
|
.where(and(eq(users.organizationId, me.organizationId), eq(users.active, true)))
|
||||||
|
.orderBy(asc(users.name))
|
||||||
|
return c.json({ users: rows })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/', requireRole('admin'), validate('json', createUserSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const { password, ...input } = c.req.valid('json')
|
||||||
|
const db = c.get('db')
|
||||||
|
try {
|
||||||
|
const [user] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({ ...input, organizationId: me.organizationId, passwordHash: password ? await hashPassword(password) : null })
|
||||||
|
.returning(publicUser)
|
||||||
|
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: user.id, action: 'create', changes: { email: user.email, role: user.role } })
|
||||||
|
if (!password) {
|
||||||
|
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId))
|
||||||
|
const token = await createAuthToken(db, user.id, 'password_reset', INVITE_TTL_MS)
|
||||||
|
c.get('notifier').passwordReset(user.email, token, { organization: organization.name, invitedBy: me.name })
|
||||||
|
}
|
||||||
|
return c.json({ user, invited: !password }, 201)
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw fail(409, 'A user with that email already exists')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.patch('/:id', requireRole('admin'), validate('param', idParam), validate('json', updateUserSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const { id } = c.req.valid('param')
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
const db = c.get('db')
|
||||||
|
|
||||||
|
const [target] = await db.select().from(users).where(and(eq(users.id, id), eq(users.organizationId, me.organizationId)))
|
||||||
|
if (!target) throw fail(404, 'User not found')
|
||||||
|
if (target.id === me.id && input.active === false) throw fail(400, 'You cannot deactivate your own account')
|
||||||
|
|
||||||
|
const losesAdmin = target.role === 'admin' && target.active && ((input.role && input.role !== 'admin') || input.active === false)
|
||||||
|
if (losesAdmin) {
|
||||||
|
const otherAdmins = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(and(eq(users.organizationId, me.organizationId), eq(users.role, 'admin'), eq(users.active, true), ne(users.id, target.id)))
|
||||||
|
if (otherAdmins.length === 0) throw fail(409, 'At least one active admin is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password, ...fields } = input
|
||||||
|
const changes: Record<string, unknown> = diff(target, fields)
|
||||||
|
if (password) changes.password = 'reset by admin'
|
||||||
|
if (Object.keys(changes).length === 0) {
|
||||||
|
const [user] = await db.select(publicUser).from(users).where(eq(users.id, id))
|
||||||
|
return c.json({ user })
|
||||||
|
}
|
||||||
|
const [user] = await db
|
||||||
|
.update(users)
|
||||||
|
.set({ ...fields, ...(password ? { passwordHash: await hashPassword(password) } : {}) })
|
||||||
|
.where(eq(users.id, id))
|
||||||
|
.returning(publicUser)
|
||||||
|
// Roles are re-read on every request, so only deactivation and password resets need to end sessions.
|
||||||
|
if (input.active === false || (password && id !== me.id)) await deleteUserSessions(db, id)
|
||||||
|
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: id, action: 'update', changes })
|
||||||
|
return c.json({ user })
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { and, asc, eq, or } from 'drizzle-orm'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { SavedView } from '../../shared/api-types.ts'
|
||||||
|
import { hasRole } from '../../shared/enums.ts'
|
||||||
|
import { savedViewSchema } from '../../shared/schemas.ts'
|
||||||
|
import { requireAuth } from '../auth/middleware.ts'
|
||||||
|
import { savedViews } from '../db/schema.ts'
|
||||||
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
|
|
||||||
|
const toView = (row: typeof savedViews.$inferSelect, userId: string): SavedView => ({
|
||||||
|
id: row.id,
|
||||||
|
page: row.page as SavedView['page'],
|
||||||
|
name: row.name,
|
||||||
|
filters: row.filters,
|
||||||
|
shared: row.shared,
|
||||||
|
mine: row.userId === userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Saved filter sets. Supervisors and above can share views with the whole organization. */
|
||||||
|
export const viewRoutes = new Hono<AppEnv>()
|
||||||
|
.get('/', requireAuth, validate('query', z.object({ page: z.enum(['analytics', 'incidents']) })), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const rows = await c
|
||||||
|
.get('db')
|
||||||
|
.select()
|
||||||
|
.from(savedViews)
|
||||||
|
.where(and(eq(savedViews.organizationId, me.organizationId), eq(savedViews.page, c.req.valid('query').page), or(eq(savedViews.userId, me.id), eq(savedViews.shared, true))))
|
||||||
|
.orderBy(asc(savedViews.name))
|
||||||
|
return c.json({ views: rows.map((row) => toView(row, me.id)) })
|
||||||
|
})
|
||||||
|
|
||||||
|
.post('/', requireAuth, validate('json', savedViewSchema), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const input = c.req.valid('json')
|
||||||
|
if (input.shared && !hasRole(me.role, 'supervisor')) throw fail(403, 'Only supervisors and above can share views')
|
||||||
|
const [row] = await c
|
||||||
|
.get('db')
|
||||||
|
.insert(savedViews)
|
||||||
|
.values({ ...input, organizationId: me.organizationId, userId: me.id })
|
||||||
|
.returning()
|
||||||
|
return c.json({ view: toView(row, me.id) }, 201)
|
||||||
|
})
|
||||||
|
|
||||||
|
.delete('/:id', requireAuth, validate('param', z.object({ id: z.uuid() })), async (c) => {
|
||||||
|
const me = currentUser(c)
|
||||||
|
const db = c.get('db')
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(savedViews)
|
||||||
|
.where(and(eq(savedViews.id, c.req.valid('param').id), eq(savedViews.organizationId, me.organizationId)))
|
||||||
|
if (!row || (!row.shared && row.userId !== me.id)) throw fail(404, 'View not found')
|
||||||
|
if (row.userId !== me.id && me.role !== 'admin') throw fail(403, 'Only the owner or an admin can delete this view')
|
||||||
|
await db.delete(savedViews).where(eq(savedViews.id, row.id))
|
||||||
|
return c.body(null, 204)
|
||||||
|
})
|
||||||