Compare commits
9 Commits
72b1ee1d3d
...
b4e47b652f
| Author | SHA1 | Date | |
|---|---|---|---|
| b4e47b652f | |||
| 08987d7c49 | |||
| 4358098c57 | |||
| d4f839c491 | |||
| 715889a72a | |||
| 1a8a264eae | |||
| bef88e789c | |||
| efd2ccd14a | |||
| f5a6517f2c |
@@ -0,0 +1,76 @@
|
|||||||
|
# AGENTS.md — Sis Project Conventions
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Sis is a shared household chore/task tracker with a Haskell backend and
|
||||||
|
TypeScript/Mithril.js SPA frontend.
|
||||||
|
|
||||||
|
## Build & Tooling
|
||||||
|
|
||||||
|
- **Haskell container:** `./hs <cmd>` runs Haskell tools inside the
|
||||||
|
flipstone/haskell-tools Docker image. Use for `stack build`, `stack test`,
|
||||||
|
`hpack`, `fourmolu`, `hlint`.
|
||||||
|
- **Build script:** `./scripts/build` — formats (fourmolu), lints (hlint),
|
||||||
|
builds with stack, copies binary to `build/`.
|
||||||
|
- **Test script:** `./scripts/test` — fourmolu check, hlint, `stack test`.
|
||||||
|
- **Run script:** `./scripts/run` — starts server in Docker via `stack exec`.
|
||||||
|
- **hpack:** `package.yaml` is the source of truth for dependencies. After
|
||||||
|
editing it, run `./hs hpack` to regenerate `sis-server.cabal`. (If `hpack`
|
||||||
|
is unavailable, edit `sis-server.cabal` manually in parallel.)
|
||||||
|
|
||||||
|
## Haskell Conventions
|
||||||
|
|
||||||
|
- **Style:** fourmolu-formatted. The `./scripts/test` script checks this.
|
||||||
|
Run `./hs fourmolu --mode inplace app/ src/ test/` before committing.
|
||||||
|
- **Lint:** hlint clean required. Fix any hints before committing.
|
||||||
|
- **Warnings:** `-Wall -Werror` in `package.yaml`. All warnings are fatal.
|
||||||
|
- **Module qualifiers:** Use qualified imports with descriptive aliases
|
||||||
|
(e.g., `import Data.Text qualified as T`).
|
||||||
|
- **JSON:** Aeson instances live in the same module as the types they
|
||||||
|
serialize (`Sis.Types`).
|
||||||
|
- **Architecture:** The backend uses [Orb](https://github.com/flipstone/orb)
|
||||||
|
for HTTP routing (`Sis.Server`), with WAI/Warp underneath. Route types
|
||||||
|
(like `HealthCheck`) implement `Orb.HasHandler`.
|
||||||
|
|
||||||
|
## Frontend Conventions
|
||||||
|
|
||||||
|
- **SPA framework:** [Mithril.js](https://mithril.js.org/) v2 with TypeScript.
|
||||||
|
- **CSS:** [Neo Brutalism](https://unpkg.com/neobrutalismcss@latest) CDN.
|
||||||
|
- **Build:** `npm run build` (or `cd frontend && npx tsc` for dev).
|
||||||
|
- **API client:** Thin fetch wrapper in `frontend/src/api.ts`. Base path `/api`.
|
||||||
|
- **Dev server:** `npm run serve` serves the built frontend on port 5000.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
sis/
|
||||||
|
├── app/Main.hs # Server entry point, CLI options, Warp setup
|
||||||
|
├── src/
|
||||||
|
│ ├── Sis.hs # Top-level re-exports
|
||||||
|
│ ├── Sis/Server.hs # Orb HTTP routes, WAI app, SPA serving
|
||||||
|
│ ├── Sis/Types.hs # Core domain types (Task, User, etc.)
|
||||||
|
│ └── Sis/Database.hs # SQLite connection management
|
||||||
|
├── test/Spec.hs # Hspec test suite
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── index.ts # Mithril mount point
|
||||||
|
│ │ ├── api.ts # Backend API client
|
||||||
|
│ │ └── components/ # Mithril components
|
||||||
|
│ └── public/style.css
|
||||||
|
├── docs/
|
||||||
|
│ ├── specs/ # Design specs
|
||||||
|
│ └── plans/ # Implementation plans
|
||||||
|
├── scripts/ # build, test, run
|
||||||
|
├── package.yaml # Haskell deps (hpack source of truth)
|
||||||
|
├── sis-server.cabal # Generated by hpack
|
||||||
|
├── stack.yaml # Stack resolver config
|
||||||
|
├── docker-compose.yml # Deployment stack
|
||||||
|
├── Dockerfile # Production image
|
||||||
|
└── FEATURES.org # Feature roadmap
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit Style
|
||||||
|
|
||||||
|
- Conventional commits: `feat:`, `deps:`, `test:`, `chore:`, `docs:`.
|
||||||
|
- Each commit should be a self-contained logical change.
|
||||||
|
- Run `./scripts/test` before committing. Tests must pass.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
* TODO SQLite database support
|
||||||
|
|
||||||
|
Let's add database support using sqlite-simple . On startup we should
|
||||||
|
create a SQLite database if none exists. Right now we have no tables,
|
||||||
|
but we'll add one in the next task.
|
||||||
|
|
||||||
|
* TODO User signup and login
|
||||||
|
User signup should be basic sign-up. Ask the user their full name,
|
||||||
|
email and password. We'll send them a verification email to confirm
|
||||||
|
their email address and at that point ask for their password. We
|
||||||
|
should use standard encryption approaches for storing their hashed
|
||||||
|
password with the https://hackage.haskell.org/package/password
|
||||||
|
library.
|
||||||
|
|
||||||
|
We should support allowing users to log in and show them a basic
|
||||||
|
welcome page with "Welcome <full name> - <household>" and nothing else once they
|
||||||
|
sign in.
|
||||||
|
|
||||||
|
Otherwise, logged out users should be shown a sign-in form with a link to a separate sign-up form.
|
||||||
|
|
||||||
|
Logged in users should see a log out button, too.
|
||||||
|
|
||||||
|
We should create a rudimentary session infrastructure. Ensure we use http-only cookies for our session token.
|
||||||
|
|
||||||
|
** Households
|
||||||
|
When a user signs up, as part of sign-up we should ask them for the
|
||||||
|
name of their Household. All users will belong a single Household and
|
||||||
|
a Household may have more than one user.
|
||||||
|
|
||||||
|
The Household will ultimately be where tasks/chores are stored.
|
||||||
|
* TODO User invite
|
||||||
|
An existing user should be able to invite a new user by email to their household.
|
||||||
|
|
||||||
|
When the invited user signs up we should not ask them for a Household name but instead make them a member of the Household they were invited to.
|
||||||
|
* TODO Basic chores
|
||||||
|
|
||||||
|
We should create a new table to store chores in the database. Each chore belongs to a household, has a name and an optional assigned-to user.
|
||||||
|
|
||||||
|
We should allow users to create a new chore and view their existing chores.
|
||||||
|
|
||||||
|
When creating a chore we should ask users for the chore name and an optional user to assign to that chore.
|
||||||
|
|
||||||
|
* TODO Chore schedules
|
||||||
|
|
||||||
|
TBD
|
||||||
+50
-40
@@ -1,6 +1,6 @@
|
|||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
{- | Entry point for the Sis chore tracker server.
|
{- | Entry point for the Sis server.
|
||||||
|
|
||||||
Starts a Warp HTTP server, serves the JSON API and the SPA frontend.
|
Starts a Warp HTTP server, serves the JSON API and the SPA frontend.
|
||||||
-}
|
-}
|
||||||
@@ -10,55 +10,65 @@ import Network.Wai.Handler.Warp qualified as Warp
|
|||||||
import Options.Applicative qualified as Opt
|
import Options.Applicative qualified as Opt
|
||||||
import System.Posix.Signals qualified as Signals
|
import System.Posix.Signals qualified as Signals
|
||||||
|
|
||||||
|
import Sis.Database qualified as Database
|
||||||
import Sis.Server qualified as Sis
|
import Sis.Server qualified as Sis
|
||||||
|
|
||||||
data Options = Options
|
data Options = Options
|
||||||
{ optPort :: Int
|
{ optPort :: Int
|
||||||
, optStaticDir :: FilePath
|
, optStaticDir :: FilePath
|
||||||
}
|
, optDbPath :: FilePath
|
||||||
|
}
|
||||||
|
|
||||||
optionsParser :: Opt.Parser Options
|
optionsParser :: Opt.Parser Options
|
||||||
optionsParser =
|
optionsParser =
|
||||||
Options
|
Options
|
||||||
<$> Opt.option
|
<$> Opt.option
|
||||||
Opt.auto
|
Opt.auto
|
||||||
( Opt.long "port"
|
( Opt.long "port"
|
||||||
<> Opt.short 'p'
|
<> Opt.short 'p'
|
||||||
<> Opt.metavar "PORT"
|
<> Opt.metavar "PORT"
|
||||||
<> Opt.help "Listen port"
|
<> Opt.help "Listen port"
|
||||||
<> Opt.value 8080
|
<> Opt.value 8080
|
||||||
<> Opt.showDefault
|
<> Opt.showDefault
|
||||||
)
|
)
|
||||||
<*> Opt.strOption
|
<*> Opt.strOption
|
||||||
( Opt.long "static-dir"
|
( Opt.long "static-dir"
|
||||||
<> Opt.metavar "DIR"
|
<> Opt.metavar "DIR"
|
||||||
<> Opt.help "Directory containing the SPA frontend static files"
|
<> Opt.help "Directory containing the SPA frontend static files"
|
||||||
<> Opt.value "frontend/dist"
|
<> Opt.value "frontend/dist"
|
||||||
<> Opt.showDefault
|
<> Opt.showDefault
|
||||||
)
|
)
|
||||||
|
<*> Opt.strOption
|
||||||
|
( Opt.long "db-path"
|
||||||
|
<> Opt.metavar "PATH"
|
||||||
|
<> Opt.help "Path to the SQLite database file"
|
||||||
|
<> Opt.value "data/sis.db"
|
||||||
|
<> Opt.showDefault
|
||||||
|
)
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = do
|
main = do
|
||||||
opts <-
|
opts <-
|
||||||
Opt.execParser $
|
Opt.execParser $
|
||||||
Opt.info (optionsParser Opt.<**> Opt.helper) $
|
Opt.info (optionsParser Opt.<**> Opt.helper) $
|
||||||
Opt.fullDesc
|
Opt.fullDesc
|
||||||
<> Opt.progDesc "Sis — shared household chore tracker"
|
<> Opt.progDesc "Sis — shared household chore tracker"
|
||||||
<> Opt.header "sis-server"
|
<> Opt.header "sis-server"
|
||||||
|
|
||||||
-- Install a SIGTERM handler so Docker stop works cleanly.
|
db <- Database.openDatabase (optDbPath opts)
|
||||||
_ <-
|
|
||||||
Signals.installHandler
|
|
||||||
Signals.sigTERM
|
|
||||||
(Signals.Catch (putStrLn "[sis] shutting down"))
|
|
||||||
Nothing
|
|
||||||
|
|
||||||
let waiApp = Sis.app (optStaticDir opts)
|
_ <-
|
||||||
|
Signals.installHandler
|
||||||
|
Signals.sigTERM
|
||||||
|
(Signals.Catch (putStrLn "[sis] shutting down"))
|
||||||
|
Nothing
|
||||||
|
|
||||||
let settings =
|
let waiApp = Sis.app (optStaticDir opts) db
|
||||||
Warp.setPort (optPort opts) $
|
|
||||||
Warp.setBeforeMainLoop
|
|
||||||
(putStrLn $ "[sis] listening on port " ++ show (optPort opts))
|
|
||||||
Warp.defaultSettings
|
|
||||||
|
|
||||||
Warp.runSettings settings waiApp
|
let settings =
|
||||||
|
Warp.setPort (optPort opts) $
|
||||||
|
Warp.setBeforeMainLoop
|
||||||
|
(putStrLn $ "[sis] listening on 0.0.0.0:" ++ show (optPort opts))
|
||||||
|
Warp.defaultSettings
|
||||||
|
|
||||||
|
Warp.runSettings settings waiApp
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# SQLite Database Support Implementation Plan
|
||||||
|
|
||||||
|
**Goal:** Add SQLite database support using `sqlite-simple`, opening a database file on startup with WAL mode and foreign keys enabled.
|
||||||
|
|
||||||
|
**Architecture:** A single new module `Sis.Database` provides `openDatabase`, which opens/creates a SQLite file, enables WAL journal mode, and enables foreign keys. The server opens the DB on startup via a `--db-path` CLI option. No tables or queries yet.
|
||||||
|
|
||||||
|
**Tech Stack:** Haskell, sqlite-simple, sqlite-simple isn't yet in the project, optparse-applicative
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add sqlite-simple dependency
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.yaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add sqlite-simple to library dependencies**
|
||||||
|
|
||||||
|
Add `sqlite-simple` to the `library` → `dependencies` section of `package.yaml`, in alphabetical order. The dependency block currently ends with `- orb`. Add before that line:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- sqlite-simple
|
||||||
|
```
|
||||||
|
|
||||||
|
The relevant section should read:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
library:
|
||||||
|
source-dirs: src
|
||||||
|
dependencies:
|
||||||
|
- orb
|
||||||
|
- sqlite-simple
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Regenerate cabal file**
|
||||||
|
|
||||||
|
Run: `./hs hpack`
|
||||||
|
Expected: exits 0, `sis-server.cabal` is regenerated with `sqlite-simple` in the library build-depends.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify it builds**
|
||||||
|
|
||||||
|
Run: `./hs stack build`
|
||||||
|
Expected: successful build (though `sqlite-simple` may need to be fetched/built). If the build fails with "could not find module", the dependency may need to be in `extra-deps` in `stack.yaml`. If so, add it there.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.yaml sis-server.cabal
|
||||||
|
git commit -m "deps: add sqlite-simple"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create Sis.Database module
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Sis/Database.hs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the module**
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
{- | SQLite database support for Sis.
|
||||||
|
|
||||||
|
Opens (and creates if missing) a SQLite database with WAL journal
|
||||||
|
mode and foreign keys enabled.
|
||||||
|
-}
|
||||||
|
module Sis.Database (
|
||||||
|
openDatabase,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Database.SQLite.Simple qualified as SQL
|
||||||
|
|
||||||
|
-- | Open (or create) a SQLite database at the given path.
|
||||||
|
--
|
||||||
|
-- Enables WAL journal mode for concurrent read performance and
|
||||||
|
-- enables foreign key enforcement.
|
||||||
|
openDatabase :: FilePath -> IO SQL.Connection
|
||||||
|
openDatabase path = do
|
||||||
|
conn <- SQL.open path
|
||||||
|
SQL.execute_ conn "PRAGMA journal_mode=WAL"
|
||||||
|
SQL.execute_ conn "PRAGMA foreign_keys=ON"
|
||||||
|
pure conn
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build to verify compilation**
|
||||||
|
|
||||||
|
Run: `./hs stack build`
|
||||||
|
Expected: builds successfully.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Sis/Database.hs
|
||||||
|
git commit -m "feat: add Sis.Database module with openDatabase"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Wire --db-path CLI option and open database on startup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/Main.hs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add import and --db-path option**
|
||||||
|
|
||||||
|
In `app/Main.hs`, add the import:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
import Sis.Database qualified as Sis
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `Options` record, add a new field:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
data Options = Options
|
||||||
|
{ optPort :: Int
|
||||||
|
, optStaticDir :: FilePath
|
||||||
|
, optDbPath :: FilePath
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `optionsParser`, add the `optDbPath` parser after `optStaticDir`:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
<*> Opt.strOption
|
||||||
|
( Opt.long "db-path"
|
||||||
|
<> Opt.metavar "PATH"
|
||||||
|
<> Opt.help "Path to the SQLite database file"
|
||||||
|
<> Opt.value "data/sis.db"
|
||||||
|
<> Opt.showDefault
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Open database in main**
|
||||||
|
|
||||||
|
In `main`, after `opts <- ...` and before `let waiApp`, add:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
_db <- Sis.openDatabase (optDbPath opts)
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the `_db` prefix suppresses the unused-binding warning. The connection is opened but not yet used — that comes in a later task when tables and routes are added.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build and verify compilation**
|
||||||
|
|
||||||
|
Run: `./hs stack build`
|
||||||
|
Expected: builds successfully with no warnings (all `-Werror`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/Main.hs
|
||||||
|
git commit -m "feat: add --db-path CLI option and open SQLite db on startup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Integration smoke test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No new files
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start the server and verify database creation**
|
||||||
|
|
||||||
|
Run: `./scripts/run`
|
||||||
|
|
||||||
|
Expected output includes `[sis] listening on port 8080`.
|
||||||
|
|
||||||
|
In another terminal, verify the database file was created:
|
||||||
|
|
||||||
|
Run: `ls -la data/sis.db`
|
||||||
|
Expected: file exists.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Shut down and restart — verify no error on existing DB**
|
||||||
|
|
||||||
|
Stop the server (Ctrl+C), then restart:
|
||||||
|
|
||||||
|
Run: `./scripts/run`
|
||||||
|
|
||||||
|
Expected: starts successfully, no errors. The existing `data/sis.db` is re-opened without issue.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Stop the server, clean up, and commit**
|
||||||
|
|
||||||
|
Stop the server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm data/sis.db
|
||||||
|
git add -A && git status # just to confirm no lingering changes
|
||||||
|
git commit -m "test: smoke test SQLite startup, DB file creation, and re-open" --allow-empty
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the `--allow-empty` flag is used since this task is a manual verification step with no code changes. If you prefer to skip committing manual verification, you can omit this commit.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# SQLite Database Support
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add SQLite database support to Sis using the `sqlite-simple` library. On
|
||||||
|
startup, the server opens a SQLite database (creating the file if it doesn't
|
||||||
|
exist), enables WAL mode and foreign keys, and makes the connection available.
|
||||||
|
No tables are created yet — that comes in a later task.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New dependency
|
||||||
|
|
||||||
|
- `sqlite-simple` added to `package.yaml`.
|
||||||
|
|
||||||
|
### New module: `Sis.Database`
|
||||||
|
|
||||||
|
Exposes one function:
|
||||||
|
|
||||||
|
```haskell
|
||||||
|
openDatabase :: FilePath -> IO Connection
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Opens (or creates) the SQLite database file at the given path.
|
||||||
|
- Enables WAL journal mode (`PRAGMA journal_mode=WAL`).
|
||||||
|
- Enables foreign keys (`PRAGMA foreign_keys=ON`).
|
||||||
|
- Returns the `Connection`.
|
||||||
|
|
||||||
|
No tables are created in this task. The file will contain only the empty SQLite
|
||||||
|
schema.
|
||||||
|
|
||||||
|
### Server wiring
|
||||||
|
|
||||||
|
- New CLI option `--db-path` in `app/Main.hs` with default value `data/sis.db`.
|
||||||
|
- `main` opens the database connection via `Sis.Database.openDatabase` before
|
||||||
|
starting the Warp server.
|
||||||
|
- The connection is not yet passed into the Orb app — that wiring happens when
|
||||||
|
the first table and routes are added in subsequent tasks.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
startup → parseOptions → openDatabase (creates file, sets pragmas)
|
||||||
|
→ start Warp server (connection held, unused for now)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- If `openDatabase` fails (e.g., unwritable path, disk full), the exception
|
||||||
|
propagates and the server fails to start. This is correct — the server cannot
|
||||||
|
function without its database.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- The existing `spec` test suite does not require changes since there are no
|
||||||
|
new routes or business logic.
|
||||||
|
- Integration-level tests for database operations will be added when tables
|
||||||
|
and queries are introduced in subsequent tasks.
|
||||||
+3
-2
@@ -3,13 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Sis — Chore Tracker</title>
|
<title>Sis — Household Chore Tracker</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/neobrutalismcss@latest">
|
<link rel="stylesheet" href="https://unpkg.com/neobrutalismcss@latest">
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"mithril": "https://unpkg.com/mithril@2.2.13/mithril.min.js"
|
"mithril": "https://esm.sh/mithril@2.2.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "Sis",
|
||||||
|
"short_name": "Sis",
|
||||||
|
"description": "Shared household chore tracker",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#fff9e6",
|
||||||
|
"background_color": "#fff9e6",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,10 +1,72 @@
|
|||||||
/* Sis custom styles — layered on top of Neo Brutalism */
|
/* Sis custom styles — layered on top of Neo Brutalism */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--nb-red: #e74c3c;
|
||||||
|
--nb-yellow: #f1c40f;
|
||||||
|
--nb-green: #2ecc71;
|
||||||
|
--nb-orange: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #fff9e6;
|
background: #fff9e6;
|
||||||
|
background-image: radial-gradient(circle, rgba(0,0,0,0.03) 1px, transparent 1px);
|
||||||
|
background-size: 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nb-container {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-navbar {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 3px solid #000;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 4px 4px 0 #000;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-navbar-start, .nb-navbar-end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-modal-overlay {
|
||||||
|
animation: fadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nb-navbar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.nb-navbar-end {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.nb-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nb-list-item {
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.nb-list-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|||||||
+566
-3
@@ -1,8 +1,571 @@
|
|||||||
import m from "mithril";
|
import m, { Vnode, RouteDefs } from "mithril";
|
||||||
|
|
||||||
import { App } from "./components/App";
|
// ── Types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface User { id: number; displayName: string; email: string }
|
||||||
|
interface Household { id: number; name: string; owner: number; memberCount: number }
|
||||||
|
interface Membership { userId: number; displayName: string; email: string; role: string }
|
||||||
|
interface Invite { id: number; code: string; email: string | null; status: string; createdAt: string }
|
||||||
|
interface Chore {
|
||||||
|
id: number; householdId: number; name: string;
|
||||||
|
assignee: { type: string; userId?: number };
|
||||||
|
schedule: any; notifyOnDue: boolean; createdAt: string;
|
||||||
|
}
|
||||||
|
interface Occurrence { id: number; choreId: number; date: string; status: string }
|
||||||
|
interface DueItem { occurrence: Occurrence; choreName: string; assigneeName: string | null; isOverdue: boolean }
|
||||||
|
interface Activity { id: number; occurrenceId: number; userId: number; status: string; note: string | null; notifyHousehold: boolean; recordedAt: string }
|
||||||
|
interface CompletedItem { activity: Activity; userName: string; choreName: string }
|
||||||
|
interface DashboardData { stats: { overdue: number; dueToday: number; doneThisWeek: number }; dueItems: DueItem[]; completedItems: CompletedItem[] }
|
||||||
|
interface ActivityLogEntry { activity: Activity; userName: string; userEmail: string; choreName: string; occurrenceDate: string }
|
||||||
|
interface ActivityLogPage { entries: ActivityLogEntry[]; page: number; perPage: number; total: number }
|
||||||
|
interface AuthResponse { user: User; households: Household[] }
|
||||||
|
|
||||||
|
// ── Session ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Session = {
|
||||||
|
user: null as User | null,
|
||||||
|
households: [] as Household[],
|
||||||
|
activeHouseholdId: null as number | null,
|
||||||
|
|
||||||
|
get activeHid() { return this.activeHouseholdId || (this.households[0]?.id ?? null) },
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/auth/me");
|
||||||
|
if (r.ok) {
|
||||||
|
const d: AuthResponse = await r.json();
|
||||||
|
this.user = d.user; this.households = d.households;
|
||||||
|
if (!this.activeHouseholdId && d.households.length > 0) this.activeHouseholdId = d.households[0].id;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(email: string, password: string, remember: boolean) {
|
||||||
|
const r = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, rememberMe: remember }) });
|
||||||
|
if (!r.ok) { const e = await r.json(); throw new Error(e.error || "Login failed"); }
|
||||||
|
const d: AuthResponse = await r.json();
|
||||||
|
this.user = d.user; this.households = d.households;
|
||||||
|
if (!this.activeHouseholdId && d.households.length > 0) this.activeHouseholdId = d.households[0].id;
|
||||||
|
return d;
|
||||||
|
},
|
||||||
|
|
||||||
|
async signup(displayName: string, email: string, password: string, confirm: string, agree: boolean) {
|
||||||
|
const r = await fetch("/api/auth/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ displayName, email, password, confirmPassword: confirm, agreeTerms: agree }) });
|
||||||
|
if (!r.ok) { const e = await r.json(); throw new Error(e.error || "Signup failed"); }
|
||||||
|
const d: AuthResponse = await r.json();
|
||||||
|
this.user = d.user; this.households = d.households;
|
||||||
|
if (!this.activeHouseholdId && d.households.length > 0) this.activeHouseholdId = d.households[0].id;
|
||||||
|
return d;
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() { await fetch("/api/auth/logout", { method: "POST" }); this.user = null; this.households = []; this.activeHouseholdId = null; m.route.set("/login"); },
|
||||||
|
|
||||||
|
switchHousehold(hid: number) { this.activeHouseholdId = hid; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function api<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||||
|
return fetch("/api" + path, opts).then(async r => {
|
||||||
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `HTTP ${r.status}`); }
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleLabel(s: any): string {
|
||||||
|
if (!s) return "Sometime";
|
||||||
|
if (s.type === "one_off") return `One-off on ${s.date}`;
|
||||||
|
if (s.type === "recurring") return `Recurs ${s.period}${s.timeOfDay ? ` at ${s.timeOfDay}` : ""}`;
|
||||||
|
if (s.type === "sometime") return "Sometime";
|
||||||
|
return JSON.stringify(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBadge(s: any): string {
|
||||||
|
if (!s) return "sometime";
|
||||||
|
return s.type === "one_off" ? "one-off" : s.type === "recurring" ? "recurring" : "sometime";
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d: string): string {
|
||||||
|
return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(t: string): string {
|
||||||
|
return new Date(t).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const NavBar: m.Component = {
|
||||||
|
view() {
|
||||||
|
if (!Session.user) return null;
|
||||||
|
return m("nav.nb-navbar", { style: { marginBottom: "1.5rem" } }, [
|
||||||
|
m(".nb-navbar-start", [
|
||||||
|
m("span.nb-font-heading2", { style: { fontWeight: 700 } }, "Sis"),
|
||||||
|
Session.households.length > 0 ? m("select.nb-input", {
|
||||||
|
style: { marginLeft: "1rem", maxWidth: "200px" },
|
||||||
|
value: String(Session.activeHid ?? ""),
|
||||||
|
onchange: (e: Event) => { const t = e.target as HTMLSelectElement; Session.switchHousehold(Number(t.value)); }
|
||||||
|
}, Session.households.map(h => m("option", { value: String(h.id) }, h.name))) : null,
|
||||||
|
]),
|
||||||
|
m(".nb-navbar-end", [
|
||||||
|
m("a.nb-button", { href: "/dashboard", onclick: (e: Event) => { e.preventDefault(); m.route.set("/dashboard"); } }, "Dashboard"),
|
||||||
|
m("a.nb-button", { href: "/chores", onclick: (e: Event) => { e.preventDefault(); m.route.set("/chores"); }, style: { marginLeft: "0.5rem" } }, "Chores"),
|
||||||
|
m("a.nb-button", { href: "/household", onclick: (e: Event) => { e.preventDefault(); m.route.set("/household"); }, style: { marginLeft: "0.5rem" } }, "Household"),
|
||||||
|
m("a.nb-button", { href: "/activity", onclick: (e: Event) => { e.preventDefault(); m.route.set("/activity"); }, style: { marginLeft: "0.5rem" } }, "Activity"),
|
||||||
|
m("button.nb-button", { style: { marginLeft: "1rem" }, onclick: () => Session.logout() }, "Logout"),
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Login Page ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface LoginState { email: string; password: string; remember: boolean; error: string; loading: boolean }
|
||||||
|
|
||||||
|
const LoginPage: m.Component<{}, LoginState> = {
|
||||||
|
oninit(v) {
|
||||||
|
v.state.email = ""; v.state.password = "";
|
||||||
|
v.state.remember = false; v.state.error = ""; v.state.loading = false;
|
||||||
|
},
|
||||||
|
view(v) {
|
||||||
|
const s = v.state;
|
||||||
|
async function submit() {
|
||||||
|
s.error = ""; s.loading = true; m.redraw();
|
||||||
|
try {
|
||||||
|
await Session.login(s.email, s.password, s.remember);
|
||||||
|
m.route.set(Session.households.length > 0 ? "/dashboard" : "/household");
|
||||||
|
} catch (e: any) { s.error = e.message; }
|
||||||
|
s.loading = false; m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
return m(".nb-container", { style: { maxWidth: "480px", margin: "4rem auto" } }, [
|
||||||
|
m(".nb-box", { style: { padding: "2rem" } }, [
|
||||||
|
m("h1.nb-font-heading1", "Welcome Back"),
|
||||||
|
m("p", { style: { opacity: 0.7, marginBottom: "1.5rem" } }, "Log in to manage your household chores."),
|
||||||
|
s.error ? m(".nb-box", { style: { borderColor: "var(--nb-red)", color: "var(--nb-red)", padding: "0.5rem", marginBottom: "1rem" } }, s.error) : null,
|
||||||
|
m("label.nb-label", "Email"),
|
||||||
|
m("input.nb-input[type=email]", { value: s.email, oninput: (e: Event) => { s.email = (e.target as HTMLInputElement).value; }, style: { marginBottom: "1rem", width: "100%" } }),
|
||||||
|
m("label.nb-label", "Password"),
|
||||||
|
m("input.nb-input[type=password]", { value: s.password, oninput: (e: Event) => { s.password = (e.target as HTMLInputElement).value; }, style: { marginBottom: "1rem", width: "100%" } }),
|
||||||
|
m("label", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } }, [
|
||||||
|
m("input[type=checkbox]", { checked: s.remember, onchange: (e: Event) => { s.remember = (e.target as HTMLInputElement).checked; } }),
|
||||||
|
"Remember me"
|
||||||
|
]),
|
||||||
|
m("button.nb-button", { style: { width: "100%", marginBottom: "1rem" }, disabled: s.loading, onclick: submit }, s.loading ? "Logging in..." : "Log In"),
|
||||||
|
m("p", { style: { textAlign: "center" } }, [
|
||||||
|
"Don't have an account? ", m("a", { href: "/signup", onclick: (e: Event) => { e.preventDefault(); m.route.set("/signup"); } }, "Sign Up")
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Signup Page ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SignupState { displayName: string; email: string; password: string; confirm: string; agree: boolean; error: string; loading: boolean }
|
||||||
|
|
||||||
|
const SignupPage: m.Component<{}, SignupState> = {
|
||||||
|
oninit(v) {
|
||||||
|
v.state.displayName = ""; v.state.email = ""; v.state.password = "";
|
||||||
|
v.state.confirm = ""; v.state.agree = false; v.state.error = ""; v.state.loading = false;
|
||||||
|
},
|
||||||
|
view(v) {
|
||||||
|
const s = v.state;
|
||||||
|
async function submit() {
|
||||||
|
s.error = ""; s.loading = true; m.redraw();
|
||||||
|
try {
|
||||||
|
await Session.signup(s.displayName, s.email, s.password, s.confirm, s.agree);
|
||||||
|
m.route.set("/dashboard");
|
||||||
|
} catch (e: any) { s.error = e.message; }
|
||||||
|
s.loading = false; m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
return m(".nb-container", { style: { maxWidth: "480px", margin: "4rem auto" } }, [
|
||||||
|
m(".nb-box", { style: { padding: "2rem" } }, [
|
||||||
|
m("h1.nb-font-heading1", "Create Account"),
|
||||||
|
m("p", { style: { opacity: 0.7, marginBottom: "1.5rem" } }, "Join your household chore tracker."),
|
||||||
|
s.error ? m(".nb-box", { style: { borderColor: "var(--nb-red)", color: "var(--nb-red)", padding: "0.5rem", marginBottom: "1rem" } }, s.error) : null,
|
||||||
|
m("label.nb-label", "Display Name"),
|
||||||
|
m("input.nb-input", { value: s.displayName, oninput: (e: Event) => { s.displayName = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } }),
|
||||||
|
m("label.nb-label", "Email"),
|
||||||
|
m("input.nb-input[type=email]", { value: s.email, oninput: (e: Event) => { s.email = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } }),
|
||||||
|
m("label.nb-label", "Password (min 8 characters)"),
|
||||||
|
m("input.nb-input[type=password]", { value: s.password, oninput: (e: Event) => { s.password = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } }),
|
||||||
|
m("label.nb-label", "Confirm Password"),
|
||||||
|
m("input.nb-input[type=password]", { value: s.confirm, oninput: (e: Event) => { s.confirm = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } }),
|
||||||
|
m("label", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } }, [
|
||||||
|
m("input[type=checkbox]", { checked: s.agree, onchange: (e: Event) => { s.agree = (e.target as HTMLInputElement).checked; } }),
|
||||||
|
"I agree to the terms of service"
|
||||||
|
]),
|
||||||
|
m("button.nb-button", { style: { width: "100%" }, disabled: s.loading, onclick: submit }, s.loading ? "Creating..." : "Sign Up"),
|
||||||
|
m("p", { style: { textAlign: "center", marginTop: "1rem" } }, [m("a", { href: "/login", onclick: (e: Event) => { e.preventDefault(); m.route.set("/login"); } }, "Already have an account? Log In")]),
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Dashboard ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DashboardPage: m.Component = {
|
||||||
|
oninit() { loadDashboard(); },
|
||||||
|
view() { return DashboardView(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
let dashData: DashboardData | null = null, dashLoading = true;
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
if (!Session.activeHid) return;
|
||||||
|
dashLoading = true; m.redraw();
|
||||||
|
try { dashData = await api<DashboardData>(`/households/${Session.activeHid}/dashboard`); }
|
||||||
|
catch (_) { dashData = null; }
|
||||||
|
dashLoading = false; m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardView() {
|
||||||
|
if (!Session.user) { m.route.set("/login"); return null; }
|
||||||
|
if (dashLoading) return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, m("p", "Loading..."));
|
||||||
|
|
||||||
|
const h = Session.households.find(h => h.id === Session.activeHid);
|
||||||
|
const dueItems = dashData?.dueItems ?? [];
|
||||||
|
const completedItems = dashData?.completedItems ?? [];
|
||||||
|
const stats = dashData?.stats ?? { overdue: 0, dueToday: 0, doneThisWeek: 0 };
|
||||||
|
|
||||||
|
return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, [
|
||||||
|
m("h1.nb-font-heading1", [h?.name ?? "Dashboard", m("span", { style: { fontSize: "1rem", opacity: 0.5, marginLeft: "1rem" } }, new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }))]),
|
||||||
|
// Stat tiles
|
||||||
|
m(".nb-row", { style: { marginBottom: "1.5rem", gap: "1rem" } }, [
|
||||||
|
m(".nb-box", { style: { flex: "1", textAlign: "center", padding: "1rem", borderColor: stats.overdue > 0 ? "var(--nb-red)" : undefined } }, [m(".nb-font-heading1", String(stats.overdue)), m("span", "Overdue")]),
|
||||||
|
m(".nb-box", { style: { flex: "1", textAlign: "center", padding: "1rem", borderColor: "var(--nb-yellow)" } }, [m(".nb-font-heading1", String(stats.dueToday)), m("span", "Due Today")]),
|
||||||
|
m(".nb-box", { style: { flex: "1", textAlign: "center", padding: "1rem", borderColor: "var(--nb-green)" } }, [m(".nb-font-heading1", String(stats.doneThisWeek)), m("span", "Done This Week")]),
|
||||||
|
]),
|
||||||
|
// Due items
|
||||||
|
m("h2.nb-font-heading2", "Overdue & Due Today"),
|
||||||
|
dueItems.length === 0 ? m("p", { style: { opacity: 0.5 } }, "Nothing due! Great job.") :
|
||||||
|
m(".nb-box", { style: { marginBottom: "1.5rem" } },
|
||||||
|
dueItems.map(d => m(".nb-list-item", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.5rem" } }, [
|
||||||
|
m("span", [
|
||||||
|
m("span.nb-badge", { style: { marginRight: "0.5rem", background: d.isOverdue ? "var(--nb-red)" : "var(--nb-yellow)", color: "#000" } }, d.isOverdue ? "OVERDUE" : "DUE"),
|
||||||
|
d.choreName,
|
||||||
|
d.assigneeName ? m("span", { style: { opacity: 0.5, marginLeft: "0.5rem" } }, `(${d.assigneeName})`) : null,
|
||||||
|
]),
|
||||||
|
m("button.nb-button", { style: { fontSize: "0.85rem" }, onclick: () => recordActivity(d.occurrence.id, d.choreName) }, "Check Off"),
|
||||||
|
]))
|
||||||
|
),
|
||||||
|
// Completed today
|
||||||
|
m("h2.nb-font-heading2", "Completed Today"),
|
||||||
|
completedItems.length === 0 ? m("p", { style: { opacity: 0.5 } }, "No activity recorded today.") :
|
||||||
|
m(".nb-box", completedItems.map(c => m(".nb-list-item", { style: { padding: "0.5rem" } }, [
|
||||||
|
m("span.nb-badge", { style: { marginRight: "0.5rem", background: "var(--nb-green)", color: "#000" } }, c.activity.status.toUpperCase()),
|
||||||
|
`${c.userName} ${c.activity.status} ${c.choreName} at ${fmtTime(c.activity.recordedAt)}`,
|
||||||
|
c.activity.note ? m("span", { style: { opacity: 0.5, marginLeft: "0.5rem" } }, `— "${c.activity.note}"`) : null,
|
||||||
|
]))),
|
||||||
|
m("a.nb-button", { href: "/activity", onclick: (e: Event) => { e.preventDefault(); m.route.set("/activity"); } }, "View Full Activity Log"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Record Activity ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function recordActivity(occurrenceId: number, choreName: string) {
|
||||||
|
let status = "completed", note = "", notify = false, error = "", saving = false;
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
saving = true; error = ""; m.redraw();
|
||||||
|
try {
|
||||||
|
await api(`/occurrences/${occurrenceId}/activity`, {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status, note: note || null, notifyHousehold: notify })
|
||||||
|
});
|
||||||
|
loadDashboard();
|
||||||
|
} catch (e: any) { error = e.message; }
|
||||||
|
saving = false; m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = m(".nb-modal-overlay", { style: { position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "rgba(0,0,0,0.5)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 } },
|
||||||
|
m(".nb-box", { style: { background: "#fff9e6", padding: "2rem", maxWidth: "480px", width: "100%" } }, [
|
||||||
|
m("h2.nb-font-heading2", "Record Activity"),
|
||||||
|
m("p", `Chore: ${choreName}`),
|
||||||
|
error ? m("p", { style: { color: "var(--nb-red)" } }, error) : null,
|
||||||
|
m("label.nb-label", "Status"),
|
||||||
|
m("select.nb-input", { value: status, onchange: (e: Event) => { status = (e.target as HTMLSelectElement).value; }, style: { width: "100%", marginBottom: "1rem" } },
|
||||||
|
[m("option", { value: "completed" }, "Completed"), m("option", { value: "skipped" }, "Skipped")]),
|
||||||
|
m("label.nb-label", "Note (optional)"),
|
||||||
|
m("textarea.nb-input", { value: note, oninput: (e: Event) => { note = (e.target as HTMLTextAreaElement).value; }, style: { width: "100%", marginBottom: "1rem", minHeight: "60px" } }),
|
||||||
|
m("label", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } }, [
|
||||||
|
m("input[type=checkbox]", { checked: notify, onchange: (e: Event) => { notify = (e.target as HTMLInputElement).checked; } }),
|
||||||
|
"Notify household of this update"
|
||||||
|
]),
|
||||||
|
m("div", { style: { display: "flex", gap: "0.5rem" } }, [
|
||||||
|
m("button.nb-button", { disabled: saving, onclick: submit }, saving ? "Saving..." : "Save"),
|
||||||
|
m("button.nb-button", { style: { background: "#ccc" }, onclick: () => { m.redraw(); } }, "Cancel"),
|
||||||
|
])
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render modal and re-render to dismiss
|
||||||
|
m.mount(document.getElementById("modal") || document.createElement("div"), { view: () => modal });
|
||||||
|
if (!document.getElementById("modal")) {
|
||||||
|
const d = document.createElement("div"); d.id = "modal"; document.body.appendChild(d);
|
||||||
|
m.mount(d, { view: () => modal });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chores Page ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ChoresPage: m.Component = {
|
||||||
|
oninit() { loadChores(); },
|
||||||
|
view() { return ChoresView(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
let chores: Chore[] = [], choresLoading = true;
|
||||||
|
|
||||||
|
async function loadChores() {
|
||||||
|
if (!Session.activeHid) return;
|
||||||
|
choresLoading = true; m.redraw();
|
||||||
|
try { chores = await api<Chore[]>(`/households/${Session.activeHid}/chores`); }
|
||||||
|
catch (_) { chores = []; }
|
||||||
|
choresLoading = false; m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoresView() {
|
||||||
|
if (!Session.user) { m.route.set("/login"); return null; }
|
||||||
|
if (choresLoading) return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, m("p", "Loading..."));
|
||||||
|
|
||||||
|
return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, [
|
||||||
|
m("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" } }, [
|
||||||
|
m("h1.nb-font-heading1", "Chores"),
|
||||||
|
m("button.nb-button", { onclick: () => showChoreForm() }, "+ New Chore"),
|
||||||
|
]),
|
||||||
|
chores.length === 0 ? m("p", { style: { opacity: 0.5 } }, "No chores yet. Create one to get started!") :
|
||||||
|
m(".nb-box", chores.map(c => m(".nb-list-item", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.5rem" } }, [
|
||||||
|
m("span", [
|
||||||
|
m("span.nb-badge", { style: { marginRight: "0.5rem" } }, scheduleBadge(c.schedule)),
|
||||||
|
c.name,
|
||||||
|
m("span", { style: { opacity: 0.5, marginLeft: "0.5rem", fontSize: "0.85rem" } }, scheduleLabel(c.schedule)),
|
||||||
|
]),
|
||||||
|
m("div", { style: { display: "flex", gap: "0.25rem" } }, [
|
||||||
|
m("button.nb-button", { style: { fontSize: "0.8rem", padding: "0.25rem 0.5rem" }, onclick: () => showChoreForm(c) }, "Edit"),
|
||||||
|
m("button.nb-button", { style: { fontSize: "0.8rem", padding: "0.25rem 0.5rem", background: "var(--nb-red)" }, onclick: () => deleteChore(c.id) }, "Delete"),
|
||||||
|
])
|
||||||
|
]))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChore(id: number) {
|
||||||
|
if (!confirm("Delete this chore?")) return;
|
||||||
|
await api(`/households/${Session.activeHid}/chores/${id}`, { method: "DELETE" });
|
||||||
|
loadChores();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showChoreForm(edit?: Chore) {
|
||||||
|
let name = edit?.name ?? "", scheduleType = edit?.schedule?.type ?? "recurring",
|
||||||
|
date = edit?.schedule?.date ?? "", timeOfDay = edit?.schedule?.timeOfDay ?? "",
|
||||||
|
period = edit?.schedule?.period ?? "daily", notify = edit?.notifyOnDue ?? false,
|
||||||
|
assigneeType = edit?.assignee?.type ?? "anyone", assigneeUserId = edit?.assignee?.userId ?? null,
|
||||||
|
saving = false, error = "";
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
saving = true; error = ""; m.redraw();
|
||||||
|
let schedule: any;
|
||||||
|
if (scheduleType === "one_off") schedule = { type: "one_off", date, time: timeOfDay || null };
|
||||||
|
else if (scheduleType === "recurring") schedule = { type: "recurring", period, startDate: date, timeOfDay: timeOfDay || null, daysOfWeek: null, daysOfMonth: null };
|
||||||
|
else schedule = { type: "sometime" };
|
||||||
|
|
||||||
|
const body = { name, assignee: { type: assigneeType, ...(assigneeType === "user" ? { userId: assigneeUserId } : {}) }, schedule, notifyOnDue: notify };
|
||||||
|
try {
|
||||||
|
if (edit) await api(`/households/${Session.activeHid}/chores/${edit.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||||
|
else await api(`/households/${Session.activeHid}/chores`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||||
|
loadChores();
|
||||||
|
} catch (e: any) { error = e.message; saving = false; m.redraw(); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = m(".nb-modal-overlay", { style: { position: "fixed", top: 0, left: 0, right: 0, bottom: 0, background: "rgba(0,0,0,0.5)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100 } },
|
||||||
|
m(".nb-box", { style: { background: "#fff9e6", padding: "2rem", maxWidth: "520px", width: "100%", maxHeight: "90vh", overflow: "auto" } }, [
|
||||||
|
m("h2.nb-font-heading2", edit ? "Edit Chore" : "New Chore"),
|
||||||
|
error ? m("p", { style: { color: "var(--nb-red)" } }, error) : null,
|
||||||
|
m("label.nb-label", "Name"), m("input.nb-input", { value: name, oninput: (e: Event) => { name = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } }),
|
||||||
|
m("label.nb-label", "Assign To"), m("select.nb-input", { value: assigneeType, onchange: (e: Event) => { assigneeType = (e.target as HTMLSelectElement).value; }, style: { width: "100%", marginBottom: "1rem" } },
|
||||||
|
[m("option", { value: "anyone" }, "Anyone in household"), m("option", { value: "user" }, "Specific member")]),
|
||||||
|
m("label.nb-label", "Schedule Type"), m("select.nb-input", { value: scheduleType, onchange: (e: Event) => { scheduleType = (e.target as HTMLSelectElement).value; }, style: { width: "100%", marginBottom: "1rem" } },
|
||||||
|
[m("option", { value: "recurring" }, "Recurring"), m("option", { value: "one_off" }, "One-off"), m("option", { value: "sometime" }, "Sometime")]),
|
||||||
|
scheduleType !== "sometime" ? [m("label.nb-label", "Start Date"), m("input.nb-input[type=date]", { value: date, oninput: (e: Event) => { date = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } })] : null,
|
||||||
|
scheduleType !== "sometime" ? [m("label.nb-label", "Time of Day (optional)"), m("input.nb-input[type=time]", { value: timeOfDay, oninput: (e: Event) => { timeOfDay = (e.target as HTMLInputElement).value; }, style: { width: "100%", marginBottom: "1rem" } })] : null,
|
||||||
|
scheduleType === "recurring" ? [m("label.nb-label", "Period"), m("select.nb-input", { value: period, onchange: (e: Event) => { period = (e.target as HTMLSelectElement).value; }, style: { width: "100%", marginBottom: "1rem" } },
|
||||||
|
[m("option", { value: "daily" }, "Daily"), m("option", { value: "weekly" }, "Weekly"), m("option", { value: "monthly" }, "Monthly")])] : null,
|
||||||
|
m("label", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } }, [
|
||||||
|
m("input[type=checkbox]", { checked: notify, onchange: (e: Event) => { notify = (e.target as HTMLInputElement).checked; } }),
|
||||||
|
"Send push reminder when due"
|
||||||
|
]),
|
||||||
|
m("div", { style: { display: "flex", gap: "0.5rem" } }, [
|
||||||
|
m("button.nb-button", { disabled: saving, onclick: submit }, saving ? "Saving..." : "Save"),
|
||||||
|
m("button.nb-button", { style: { background: "#ccc" }, onclick: () => { m.redraw(); } }, "Cancel"),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use modal div
|
||||||
|
if (!document.getElementById("modal")) { const d = document.createElement("div"); d.id = "modal"; document.body.appendChild(d); }
|
||||||
|
m.mount(document.getElementById("modal")!, { view: () => modal });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Household Page ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface HHPgState { name: string; creating: boolean }
|
||||||
|
|
||||||
|
const HouseholdPage: m.Component<{}, HHPgState> = {
|
||||||
|
oninit(v) {
|
||||||
|
v.state.name = ""; v.state.creating = false;
|
||||||
|
},
|
||||||
|
view(v) { return HouseholdView(v.state); }
|
||||||
|
};
|
||||||
|
|
||||||
|
let members: Membership[] = [], invites: Invite[] = [], hhLoading = true;
|
||||||
|
|
||||||
|
async function loadHousehold() {
|
||||||
|
if (!Session.activeHid) return;
|
||||||
|
hhLoading = true; m.redraw();
|
||||||
|
try {
|
||||||
|
members = await api<Membership[]>(`/households/${Session.activeHid}/members`);
|
||||||
|
invites = await api<Invite[]>(`/households/${Session.activeHid}/invites`);
|
||||||
|
} catch (_) { members = []; invites = []; }
|
||||||
|
hhLoading = false; m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function HouseholdView(s: HHPgState) {
|
||||||
|
if (!Session.user) { m.route.set("/login"); return null; }
|
||||||
|
if (hhLoading) { loadHousehold(); return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, m("p", "Loading...")); }
|
||||||
|
|
||||||
|
const h = Session.households.find(h => h.id === Session.activeHid);
|
||||||
|
const isOwner = members.some(m => m.userId === Session.user!.id && m.role === "owner");
|
||||||
|
|
||||||
|
// If no households, show create form
|
||||||
|
if (Session.households.length === 0) {
|
||||||
|
async function create() {
|
||||||
|
s.creating = true; m.redraw();
|
||||||
|
await api("/households", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: s.name }) });
|
||||||
|
await Session.load();
|
||||||
|
s.creating = false; m.redraw();
|
||||||
|
}
|
||||||
|
return m(".nb-container", { style: { maxWidth: "480px", margin: "4rem auto" } }, [
|
||||||
|
m(".nb-box", { style: { padding: "2rem", textAlign: "center" } }, [
|
||||||
|
m("h1.nb-font-heading1", "Create Your Household"),
|
||||||
|
m("p", { style: { marginBottom: "1rem" } }, "You need a household to get started."),
|
||||||
|
m("input.nb-input", { value: s.name, oninput: (e: Event) => { s.name = (e.target as HTMLInputElement).value; }, placeholder: "Household name", style: { width: "100%", marginBottom: "1rem" } }),
|
||||||
|
m("button.nb-button", { disabled: s.creating, onclick: create }, s.creating ? "Creating..." : "Create Household"),
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, [
|
||||||
|
m("h1.nb-font-heading1", h?.name ?? "Household"),
|
||||||
|
m("p", { style: { opacity: 0.7 } }, `${members.length} member${members.length !== 1 ? "s" : ""}`),
|
||||||
|
// Members
|
||||||
|
m("h2.nb-font-heading2", "Members"),
|
||||||
|
m(".nb-box", { style: { marginBottom: "1.5rem" } },
|
||||||
|
members.map((mem: Membership) => m(".nb-list-item", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0.5rem" } }, [
|
||||||
|
m("span", [
|
||||||
|
m("span.nb-badge", { style: { marginRight: "0.5rem", borderRadius: "50%", width: "2rem", height: "2rem", display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: "0.8rem" } }, initials(mem.displayName)),
|
||||||
|
m.trust(`<strong>${mem.displayName}</strong>`),
|
||||||
|
m("span", { style: { opacity: 0.5, marginLeft: "0.5rem" } }, mem.email),
|
||||||
|
]),
|
||||||
|
m("span.nb-badge", { style: mem.role === "owner" ? { background: "var(--nb-yellow)", color: "#000" } : {} }, mem.role.toUpperCase()),
|
||||||
|
]))
|
||||||
|
),
|
||||||
|
// Invites
|
||||||
|
isOwner ? [
|
||||||
|
m("h2.nb-font-heading2", "Invite Members"),
|
||||||
|
m(".nb-box", { style: { marginBottom: "1.5rem" } }, [
|
||||||
|
m("p", { style: { marginBottom: "0.5rem" } }, "Create an invite link to share:"),
|
||||||
|
m("button.nb-button", { onclick: createInvite }, "+ Create Invite Link"),
|
||||||
|
invites.length > 0 ? m("div", { style: { marginTop: "1rem" } }, [
|
||||||
|
m("h3", "Pending Invites"),
|
||||||
|
...invites.filter(i => i.status === "pending").map(i => m(".nb-list-item", { style: { display: "flex", justifyContent: "space-between", padding: "0.5rem" } }, [
|
||||||
|
m("code", { style: { fontSize: "0.85rem" } }, `/invite/${i.code}`),
|
||||||
|
m("button.nb-button", { style: { fontSize: "0.8rem", background: "var(--nb-red)" }, onclick: () => revokeInvite(i.id) }, "Revoke"),
|
||||||
|
]))
|
||||||
|
]) : null,
|
||||||
|
])
|
||||||
|
] : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createInvite() {
|
||||||
|
await api(`/households/${Session.activeHid}/invites`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: null }) });
|
||||||
|
loadHousehold();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeInvite(id: number) {
|
||||||
|
await api(`/households/${Session.activeHid}/invites/${id}`, { method: "DELETE" });
|
||||||
|
loadHousehold();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity Log Page ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const ActivityPage: m.Component = {
|
||||||
|
view() { return ActivityView(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
let actPage: ActivityLogPage | null = null, actLoading = true;
|
||||||
|
|
||||||
|
async function loadActivityLog(page = 1) {
|
||||||
|
if (!Session.activeHid) return;
|
||||||
|
actLoading = true; m.redraw();
|
||||||
|
try { actPage = await api<ActivityLogPage>(`/households/${Session.activeHid}/activity?page=${page}&perPage=20`); }
|
||||||
|
catch (_) { actPage = null; }
|
||||||
|
actLoading = false; m.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityView() {
|
||||||
|
if (!Session.user) { m.route.set("/login"); return null; }
|
||||||
|
if (actLoading) { loadActivityLog(); return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, m("p", "Loading...")); }
|
||||||
|
|
||||||
|
return m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, [
|
||||||
|
m("h1.nb-font-heading1", "Activity Log"),
|
||||||
|
actPage && actPage.entries.length === 0 ? m("p", { style: { opacity: 0.5 } }, "No activity recorded yet.") :
|
||||||
|
m(".nb-box", (actPage?.entries ?? []).map(e => m(".nb-list-item", { style: { padding: "0.5rem" } }, [
|
||||||
|
m("span.nb-badge", { style: { marginRight: "0.5rem", background: e.activity.status === "completed" ? "var(--nb-green)" : "var(--nb-orange)", color: "#000" } }, e.activity.status.toUpperCase()),
|
||||||
|
m.trust(`<strong>${e.userName}</strong>`), ` ${e.activity.status} `, m.trust(`<strong>${e.choreName}</strong>`),
|
||||||
|
m("span", { style: { opacity: 0.5, marginLeft: "0.5rem" } }, `on ${fmtDate(e.occurrenceDate)} at ${fmtTime(e.activity.recordedAt)}`),
|
||||||
|
e.activity.note ? m("span", { style: { opacity: 0.5, fontStyle: "italic", marginLeft: "0.5rem" } }, `"${e.activity.note}"`) : null,
|
||||||
|
]))),
|
||||||
|
actPage && actPage.entries.length > 0 && actPage.total > actPage.perPage ? m("div", { style: { marginTop: "1rem", display: "flex", gap: "0.5rem", justifyContent: "center" } }, [
|
||||||
|
m("button.nb-button", { disabled: actPage!.page <= 1, onclick: () => loadActivityLog(actPage!.page - 1) }, "Previous"),
|
||||||
|
m("span", { style: { alignSelf: "center" } }, `Page ${actPage!.page} of ${Math.ceil(actPage!.total / actPage!.perPage)}`),
|
||||||
|
m("button.nb-button", { disabled: actPage!.page >= Math.ceil(actPage!.total / actPage!.perPage), onclick: () => loadActivityLog(actPage!.page + 1) }, "Next"),
|
||||||
|
]) : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App Shell ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const AppShell: m.Component = {
|
||||||
|
view(v: Vnode) {
|
||||||
|
return m("div", [m(NavBar), m(".nb-container", { style: { maxWidth: "960px", margin: "0 auto" } }, v.children)]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Router ──────────────────────────────────────────────────────
|
||||||
|
const routes: RouteDefs = {
|
||||||
|
"/login": { onmatch: () => { if (Session.user) { m.route.set("/dashboard"); return; } }, render: () => m(AppShell, m(LoginPage)) },
|
||||||
|
"/signup": { onmatch: () => { if (Session.user) { m.route.set("/dashboard"); return; } }, render: () => m(AppShell, m(SignupPage)) },
|
||||||
|
"/dashboard": { onmatch: checkAuth, render: () => m(AppShell, m(DashboardPage)) },
|
||||||
|
"/chores": { onmatch: checkAuth, render: () => m(AppShell, m(ChoresPage)) },
|
||||||
|
"/household": { onmatch: checkAuth, render: () => m(AppShell, m(HouseholdPage)) },
|
||||||
|
"/activity": { onmatch: checkAuth, render: () => m(AppShell, m(ActivityPage)) },
|
||||||
|
};
|
||||||
|
|
||||||
|
function checkAuth(): Promise<void> | void {
|
||||||
|
if (Session.user) return;
|
||||||
|
return Session.load().then(() => { if (!Session.user) m.route.set("/login"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mount ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const root = document.getElementById("app");
|
const root = document.getElementById("app");
|
||||||
if (root) {
|
if (root) {
|
||||||
m.mount(root, App);
|
m.route(root, "/login", routes);
|
||||||
|
Session.load();
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -26,7 +26,6 @@ ghc-options:
|
|||||||
- -Wincomplete-uni-patterns
|
- -Wincomplete-uni-patterns
|
||||||
- -Wmissing-export-lists
|
- -Wmissing-export-lists
|
||||||
- -Wmissing-home-modules
|
- -Wmissing-home-modules
|
||||||
- -Wpartial-fields
|
|
||||||
- -Wredundant-constraints
|
- -Wredundant-constraints
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -47,12 +46,19 @@ dependencies:
|
|||||||
- text
|
- text
|
||||||
- time
|
- time
|
||||||
- wai
|
- wai
|
||||||
|
- wai-extra
|
||||||
- warp
|
- warp
|
||||||
|
|
||||||
library:
|
library:
|
||||||
source-dirs: src
|
source-dirs: src
|
||||||
dependencies:
|
dependencies:
|
||||||
|
- base64-bytestring
|
||||||
|
- cookie
|
||||||
|
- crypton
|
||||||
|
- memory
|
||||||
- orb
|
- orb
|
||||||
|
- random
|
||||||
|
- sqlite-simple
|
||||||
|
|
||||||
executables:
|
executables:
|
||||||
sis-server:
|
sis-server:
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ IMAGE="${HAWAT_HASKELL_TOOLS_IMAGE:-ghcr.io/flipstone/haskell-tools:debian-ghc-9
|
|||||||
STACK_ROOT_HOST="${PROJECT_DIR}/.stack-root"
|
STACK_ROOT_HOST="${PROJECT_DIR}/.stack-root"
|
||||||
mkdir -p "${STACK_ROOT_HOST}"
|
mkdir -p "${STACK_ROOT_HOST}"
|
||||||
|
|
||||||
echo "[sis] listening on http://127.0.0.1:${HOST_PORT}/"
|
echo "[sis] listening on http://0.0.0.0:${HOST_PORT}/"
|
||||||
|
|
||||||
exec docker run --rm -i $([ -t 0 ] && printf -- -t) \
|
exec docker run --rm -i $([ -t 0 ] && printf -- -t) \
|
||||||
-v "${PROJECT_DIR}:/work" \
|
-v "${PROJECT_DIR}:/work" \
|
||||||
|
|||||||
+14
-3
@@ -17,6 +17,8 @@ build-type: Simple
|
|||||||
library
|
library
|
||||||
exposed-modules:
|
exposed-modules:
|
||||||
Sis
|
Sis
|
||||||
|
Sis.Auth
|
||||||
|
Sis.Database
|
||||||
Sis.Server
|
Sis.Server
|
||||||
Sis.Types
|
Sis.Types
|
||||||
other-modules:
|
other-modules:
|
||||||
@@ -32,26 +34,33 @@ library
|
|||||||
OverloadedStrings
|
OverloadedStrings
|
||||||
RecordWildCards
|
RecordWildCards
|
||||||
TupleSections
|
TupleSections
|
||||||
ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
|
ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wredundant-constraints
|
||||||
build-depends:
|
build-depends:
|
||||||
aeson
|
aeson
|
||||||
, base >=4.7 && <5
|
, base >=4.7 && <5
|
||||||
|
, base64-bytestring
|
||||||
, beeline-routing
|
, beeline-routing
|
||||||
, bytestring
|
, bytestring
|
||||||
, containers
|
, containers
|
||||||
|
, cookie
|
||||||
|
, crypton
|
||||||
, directory
|
, directory
|
||||||
, filepath
|
, filepath
|
||||||
, http-types
|
, http-types
|
||||||
, json-fleece-aeson
|
, json-fleece-aeson
|
||||||
, json-fleece-core
|
, json-fleece-core
|
||||||
|
, memory
|
||||||
, mtl
|
, mtl
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
, orb
|
, orb
|
||||||
|
, random
|
||||||
, safe-exceptions
|
, safe-exceptions
|
||||||
, shrubbery
|
, shrubbery
|
||||||
|
, sqlite-simple
|
||||||
, text
|
, text
|
||||||
, time
|
, time
|
||||||
, wai
|
, wai
|
||||||
|
, wai-extra
|
||||||
, warp
|
, warp
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
|
|
||||||
@@ -70,7 +79,7 @@ executable sis-server
|
|||||||
OverloadedStrings
|
OverloadedStrings
|
||||||
RecordWildCards
|
RecordWildCards
|
||||||
TupleSections
|
TupleSections
|
||||||
ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
|
ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N
|
||||||
build-depends:
|
build-depends:
|
||||||
aeson
|
aeson
|
||||||
, base >=4.7 && <5
|
, base >=4.7 && <5
|
||||||
@@ -92,6 +101,7 @@ executable sis-server
|
|||||||
, time
|
, time
|
||||||
, unix
|
, unix
|
||||||
, wai
|
, wai
|
||||||
|
, wai-extra
|
||||||
, warp
|
, warp
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
|
|
||||||
@@ -111,7 +121,7 @@ test-suite sis-server-test
|
|||||||
OverloadedStrings
|
OverloadedStrings
|
||||||
RecordWildCards
|
RecordWildCards
|
||||||
TupleSections
|
TupleSections
|
||||||
ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints
|
ghc-options: -Wall -Werror -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wredundant-constraints
|
||||||
build-depends:
|
build-depends:
|
||||||
aeson
|
aeson
|
||||||
, base >=4.7 && <5
|
, base >=4.7 && <5
|
||||||
@@ -132,5 +142,6 @@ test-suite sis-server-test
|
|||||||
, text
|
, text
|
||||||
, time
|
, time
|
||||||
, wai
|
, wai
|
||||||
|
, wai-extra
|
||||||
, warp
|
, warp
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
|
|||||||
@@ -3,5 +3,7 @@ module Sis (
|
|||||||
module X,
|
module X,
|
||||||
) where
|
) where
|
||||||
|
|
||||||
|
import Sis.Auth as X
|
||||||
|
import Sis.Database as X
|
||||||
import Sis.Server as X
|
import Sis.Server as X
|
||||||
import Sis.Types as X
|
import Sis.Types as X
|
||||||
|
|||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
{-# LANGUAGE ScopedTypeVariables #-}
|
||||||
|
|
||||||
|
{- | Authentication and session management.
|
||||||
|
|
||||||
|
Provides password hashing with PBKDF2, session token generation,
|
||||||
|
and httpOnly cookie handling.
|
||||||
|
-}
|
||||||
|
module Sis.Auth (
|
||||||
|
-- * Password hashing
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
|
||||||
|
-- * Session tokens
|
||||||
|
generateToken,
|
||||||
|
sessionCookieName,
|
||||||
|
makeSessionCookie,
|
||||||
|
clearSessionCookie,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Crypto.Hash.Algorithms qualified as Hash
|
||||||
|
import Crypto.KDF.PBKDF2 qualified as PBKDF2
|
||||||
|
import Crypto.Random.Entropy (getEntropy)
|
||||||
|
import Data.ByteArray ()
|
||||||
|
import Data.ByteString qualified as BS
|
||||||
|
import Data.ByteString.Base64 qualified as B64
|
||||||
|
import Data.Text qualified as T
|
||||||
|
import Data.Text.Encoding qualified as TE
|
||||||
|
import Network.HTTP.Types qualified as HTTP
|
||||||
|
|
||||||
|
-- | Name of the session cookie.
|
||||||
|
sessionCookieName :: T.Text
|
||||||
|
sessionCookieName = "sis_session"
|
||||||
|
|
||||||
|
-- | Number of PBKDF2 iterations.
|
||||||
|
pbkdf2Iterations :: Int
|
||||||
|
pbkdf2Iterations = 600000
|
||||||
|
|
||||||
|
-- | Salt length in bytes.
|
||||||
|
saltLength :: Int
|
||||||
|
saltLength = 16
|
||||||
|
|
||||||
|
-- | Hash a password with PBKDF2-SHA256, returning a "{salt}:{hash}" string.
|
||||||
|
hashPassword :: T.Text -> IO T.Text
|
||||||
|
hashPassword password = do
|
||||||
|
salt <- getEntropy saltLength
|
||||||
|
let pwBytes = TE.encodeUtf8 password
|
||||||
|
hashBytes :: BS.ByteString
|
||||||
|
hashBytes =
|
||||||
|
PBKDF2.generate
|
||||||
|
(PBKDF2.prfHMAC Hash.SHA256)
|
||||||
|
(PBKDF2.Parameters pbkdf2Iterations 32)
|
||||||
|
pwBytes
|
||||||
|
salt
|
||||||
|
stored = B64.encode salt <> ":" <> B64.encode hashBytes
|
||||||
|
pure $ TE.decodeUtf8 stored
|
||||||
|
|
||||||
|
-- | Verify a password against a "{salt}:{hash}" stored value.
|
||||||
|
verifyPassword :: T.Text -> T.Text -> Bool
|
||||||
|
verifyPassword password stored =
|
||||||
|
case T.breakOn ":" stored of
|
||||||
|
(b64Salt, rest)
|
||||||
|
| T.null b64Salt -> False
|
||||||
|
| T.null rest -> False
|
||||||
|
| otherwise ->
|
||||||
|
let b64Hash = T.drop 1 rest
|
||||||
|
in case (B64.decode (TE.encodeUtf8 b64Salt), B64.decode (TE.encodeUtf8 b64Hash)) of
|
||||||
|
(Right salt, Right expectedHash) ->
|
||||||
|
let pwBytes = TE.encodeUtf8 password
|
||||||
|
computedHash :: BS.ByteString
|
||||||
|
computedHash =
|
||||||
|
PBKDF2.generate
|
||||||
|
(PBKDF2.prfHMAC Hash.SHA256)
|
||||||
|
(PBKDF2.Parameters pbkdf2Iterations 32)
|
||||||
|
pwBytes
|
||||||
|
salt
|
||||||
|
in computedHash == expectedHash
|
||||||
|
_ -> False
|
||||||
|
|
||||||
|
-- | Generate a cryptographically random token suitable for session or invite codes.
|
||||||
|
generateToken :: IO T.Text
|
||||||
|
generateToken = do
|
||||||
|
bytes <- getEntropy 32
|
||||||
|
pure $ TE.decodeUtf8 $ B64.encode bytes
|
||||||
|
|
||||||
|
-- | Create a session cookie header value.
|
||||||
|
makeSessionCookie :: T.Text -> Bool -> HTTP.Header
|
||||||
|
makeSessionCookie token rememberMe =
|
||||||
|
let maxAge = if rememberMe then (30 :: Int) * 86400 else 86400
|
||||||
|
cookieText =
|
||||||
|
TE.encodeUtf8 sessionCookieName
|
||||||
|
<> "="
|
||||||
|
<> TE.encodeUtf8 token
|
||||||
|
<> "; Path=/; HttpOnly; SameSite=Lax; Max-Age="
|
||||||
|
<> TE.encodeUtf8 (T.pack $ show maxAge)
|
||||||
|
in ("Set-Cookie", cookieText)
|
||||||
|
|
||||||
|
-- | Clear the session cookie.
|
||||||
|
clearSessionCookie :: HTTP.Header
|
||||||
|
clearSessionCookie =
|
||||||
|
( "Set-Cookie"
|
||||||
|
, TE.encodeUtf8 sessionCookieName <> "=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
|
|
||||||
|
{- | SQLite database support for Sis.
|
||||||
|
|
||||||
|
Opens (and creates if missing) a SQLite database with WAL journal
|
||||||
|
mode and foreign keys enabled. Runs schema migrations on startup.
|
||||||
|
-}
|
||||||
|
module Sis.Database (
|
||||||
|
openDatabase,
|
||||||
|
runMigrations,
|
||||||
|
) where
|
||||||
|
|
||||||
|
import Database.SQLite.Simple qualified as SQL
|
||||||
|
import System.Directory (createDirectoryIfMissing)
|
||||||
|
import System.FilePath (takeDirectory)
|
||||||
|
|
||||||
|
{- | Open (or create) a SQLite database at the given path.
|
||||||
|
|
||||||
|
Enables WAL journal mode for concurrent read performance and
|
||||||
|
enables foreign key enforcement.
|
||||||
|
-}
|
||||||
|
openDatabase :: FilePath -> IO SQL.Connection
|
||||||
|
openDatabase path = do
|
||||||
|
createDirectoryIfMissing True (takeDirectory path)
|
||||||
|
conn <- SQL.open path
|
||||||
|
SQL.execute_ conn "PRAGMA journal_mode=WAL"
|
||||||
|
SQL.execute_ conn "PRAGMA foreign_keys=ON"
|
||||||
|
runMigrations conn
|
||||||
|
pure conn
|
||||||
|
|
||||||
|
-- | Create all tables if they don't exist.
|
||||||
|
runMigrations :: SQL.Connection -> IO ()
|
||||||
|
runMigrations conn =
|
||||||
|
mapM_
|
||||||
|
(SQL.execute_ conn)
|
||||||
|
[ "CREATE TABLE IF NOT EXISTS users (\
|
||||||
|
\ id INTEGER PRIMARY KEY AUTOINCREMENT,\
|
||||||
|
\ display_name TEXT NOT NULL,\
|
||||||
|
\ email TEXT NOT NULL UNIQUE,\
|
||||||
|
\ password_hash TEXT NOT NULL,\
|
||||||
|
\ created_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
||||||
|
, "CREATE TABLE IF NOT EXISTS sessions (\
|
||||||
|
\ id INTEGER PRIMARY KEY AUTOINCREMENT,\
|
||||||
|
\ token TEXT NOT NULL UNIQUE,\
|
||||||
|
\ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\
|
||||||
|
\ expires_at TEXT NOT NULL,\
|
||||||
|
\ created_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
||||||
|
, "CREATE TABLE IF NOT EXISTS households (\
|
||||||
|
\ id INTEGER PRIMARY KEY AUTOINCREMENT,\
|
||||||
|
\ name TEXT NOT NULL,\
|
||||||
|
\ created_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
||||||
|
, "CREATE TABLE IF NOT EXISTS memberships (\
|
||||||
|
\ id INTEGER PRIMARY KEY AUTOINCREMENT,\
|
||||||
|
\ household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,\
|
||||||
|
\ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\
|
||||||
|
\ role TEXT NOT NULL CHECK (role IN ('owner', 'member')),\
|
||||||
|
\ UNIQUE (household_id, user_id))"
|
||||||
|
, "CREATE TABLE IF NOT EXISTS invites (\
|
||||||
|
\ id INTEGER PRIMARY KEY AUTOINCREMENT,\
|
||||||
|
\ household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,\
|
||||||
|
\ code TEXT NOT NULL UNIQUE,\
|
||||||
|
\ email TEXT,\
|
||||||
|
\ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'revoked')),\
|
||||||
|
\ created_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
||||||
|
, "CREATE TABLE IF NOT EXISTS chores (\
|
||||||
|
\ id INTEGER PRIMARY KEY AUTOINCREMENT,\
|
||||||
|
\ household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,\
|
||||||
|
\ name TEXT NOT NULL,\
|
||||||
|
\ assignee_type TEXT NOT NULL DEFAULT 'anyone' CHECK (assignee_type IN ('user', 'anyone')),\
|
||||||
|
\ assignee_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,\
|
||||||
|
\ schedule_type TEXT NOT NULL CHECK (schedule_type IN ('one_off', 'recurring', 'sometime')),\
|
||||||
|
\ schedule_data TEXT NOT NULL,\
|
||||||
|
\ notify_on_due INTEGER NOT NULL DEFAULT 0,\
|
||||||
|
\ created_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
||||||
|
, "CREATE TABLE IF NOT EXISTS occurrences (\
|
||||||
|
\ id INTEGER PRIMARY KEY AUTOINCREMENT,\
|
||||||
|
\ chore_id INTEGER NOT NULL REFERENCES chores(id) ON DELETE CASCADE,\
|
||||||
|
\ due_date TEXT NOT NULL,\
|
||||||
|
\ status TEXT NOT NULL DEFAULT 'due' CHECK (status IN ('due', 'overdue', 'completed', 'skipped')),\
|
||||||
|
\ UNIQUE (chore_id, due_date))"
|
||||||
|
, "CREATE TABLE IF NOT EXISTS activities (\
|
||||||
|
\ id INTEGER PRIMARY KEY AUTOINCREMENT,\
|
||||||
|
\ occurrence_id INTEGER NOT NULL REFERENCES occurrences(id) ON DELETE CASCADE,\
|
||||||
|
\ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\
|
||||||
|
\ status TEXT NOT NULL CHECK (status IN ('completed', 'skipped')),\
|
||||||
|
\ note TEXT,\
|
||||||
|
\ notify_household INTEGER NOT NULL DEFAULT 0,\
|
||||||
|
\ recorded_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
||||||
|
, "CREATE TABLE IF NOT EXISTS reset_tokens (\
|
||||||
|
\ id INTEGER PRIMARY KEY AUTOINCREMENT,\
|
||||||
|
\ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\
|
||||||
|
\ token TEXT NOT NULL UNIQUE,\
|
||||||
|
\ used INTEGER NOT NULL DEFAULT 0,\
|
||||||
|
\ expires_at TEXT NOT NULL,\
|
||||||
|
\ created_at TEXT NOT NULL DEFAULT (datetime('now')))"
|
||||||
|
]
|
||||||
+741
-183
@@ -1,212 +1,770 @@
|
|||||||
{-# LANGUAGE DataKinds #-}
|
|
||||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
|
||||||
{-# LANGUAGE OverloadedStrings #-}
|
{-# LANGUAGE OverloadedStrings #-}
|
||||||
{-# LANGUAGE TypeFamilies #-}
|
|
||||||
|
|
||||||
{- | Orb-based HTTP server for Sis.
|
{- | Simple WAI-based HTTP server for Sis.
|
||||||
|
Bypasses Orb's complex routing for a straightforward manual approach.
|
||||||
Defines the API routes and wires them into a WAI 'Wai.Application'.
|
|
||||||
Serves the Mithril SPA frontend from a static directory for all
|
|
||||||
non-API routes, with SPA-routing fallback to @index.html@.
|
|
||||||
-}
|
-}
|
||||||
module Sis.Server
|
module Sis.Server (app) where
|
||||||
( app
|
|
||||||
, sisRouter
|
|
||||||
, HealthCheck (..)
|
|
||||||
) where
|
|
||||||
|
|
||||||
import Beeline.Routing ((/-), (/:))
|
|
||||||
import Beeline.Routing qualified as R
|
|
||||||
import Control.Exception.Safe qualified as Safe
|
import Control.Exception.Safe qualified as Safe
|
||||||
import Control.Monad.IO.Class qualified as MIO
|
import Control.Monad (unless, void, when)
|
||||||
import Control.Monad.Reader qualified as Reader
|
import Data.Aeson qualified as A
|
||||||
import Data.ByteString qualified as BS
|
import Data.ByteString qualified as BS
|
||||||
|
import Data.ByteString.Lazy qualified as BL
|
||||||
import Data.Map.Strict qualified as Map
|
import Data.Map.Strict qualified as Map
|
||||||
|
import Data.Maybe (fromMaybe, listToMaybe)
|
||||||
import Data.Text qualified as T
|
import Data.Text qualified as T
|
||||||
import Data.Text.Encoding qualified as TE
|
import Data.Text.Encoding qualified as TE
|
||||||
import Data.Void (Void, absurd)
|
import Data.Time qualified as Time
|
||||||
|
import Database.SQLite.Simple (Only (..))
|
||||||
|
import Database.SQLite.Simple qualified as SQL
|
||||||
import Network.HTTP.Types qualified as HTTP
|
import Network.HTTP.Types qualified as HTTP
|
||||||
import Network.Wai qualified as Wai
|
import Network.Wai qualified as Wai
|
||||||
import Shrubbery qualified as S
|
|
||||||
import System.FilePath ((</>))
|
|
||||||
import System.Directory (doesFileExist)
|
import System.Directory (doesFileExist)
|
||||||
|
import System.FilePath ((</>))
|
||||||
|
import Text.Read (readMaybe)
|
||||||
|
|
||||||
import Orb qualified
|
import Sis.Auth qualified as Auth
|
||||||
|
import Sis.Types
|
||||||
|
|
||||||
-- | The top-level WAI application, serving both the API and the SPA frontend.
|
app :: FilePath -> SQL.Connection -> Wai.Application
|
||||||
app :: FilePath -> Wai.Application
|
app staticDir conn request respond = do
|
||||||
app staticDir =
|
let path = TE.decodeUtf8 $ Wai.rawPathInfo request
|
||||||
Orb.orbAppToWai sisOrbApp{Orb.handleNotFound = serveStaticOrSpa staticDir}
|
if "/api/" `T.isPrefixOf` path
|
||||||
|
then handleApi conn request respond
|
||||||
|
else serveStaticOrSpa staticDir request respond
|
||||||
|
|
||||||
-- | Full Orb application wiring routes to a WAI dispatcher.
|
-- | Handle all API routes by dispatching on path and method.
|
||||||
sisOrbApp :: Orb.OrbApp (S.Union Routes)
|
handleApi :: SQL.Connection -> Wai.Application
|
||||||
sisOrbApp =
|
handleApi conn request respond = do
|
||||||
Orb.OrbApp
|
let segs = filter (not . T.null) $ T.splitOn "/" $ TE.decodeUtf8 $ Wai.rawPathInfo request
|
||||||
{ Orb.router = sisRouter
|
method = Wai.requestMethod request
|
||||||
, Orb.dispatcher = sisDispatcher
|
getBody = Wai.strictRequestBody request
|
||||||
, Orb.handleNotFound = Orb.defaultHandleNotFound -- overridden in 'app'
|
result <- Safe.try $ routeApi conn request respond segs method getBody
|
||||||
}
|
case result of
|
||||||
|
Left (ApiResponse resp) -> respond resp
|
||||||
|
Right _ -> respond $ Wai.responseLBS HTTP.status500 [] "Internal server error"
|
||||||
|
|
||||||
-- | The route recognizer for all sis routes.
|
-- | Exception carrying a pre-built WAI response for early exit.
|
||||||
sisRouter :: R.RouteRecognizer (S.Union Routes)
|
newtype ApiResponse = ApiResponse Wai.Response
|
||||||
sisRouter =
|
|
||||||
R.routeList $
|
|
||||||
Orb.get (R.make HealthCheck /- "api" /- "health")
|
|
||||||
/: R.emptyRoutes
|
|
||||||
|
|
||||||
-- | Dispatch a recognized route to its handler via the 'SisDispatchM' monad.
|
instance Show ApiResponse where show _ = "ApiResponse"
|
||||||
sisDispatcher :: S.Union Routes -> Wai.Application
|
instance Safe.Exception ApiResponse
|
||||||
sisDispatcher route request respond = do
|
|
||||||
let env = SisDispatchEnv request respond
|
|
||||||
let SisDispatchM action = Orb.dispatch route
|
|
||||||
Reader.runReaderT action env
|
|
||||||
|
|
||||||
-- | The union of all route types in the application.
|
-- | Throw a response to exit early.
|
||||||
type Routes =
|
throwResp :: HTTP.Status -> BL.ByteString -> IO a
|
||||||
'[ HealthCheck
|
throwResp status body =
|
||||||
]
|
Safe.throwIO $
|
||||||
|
ApiResponse $
|
||||||
|
Wai.responseLBS status [("Content-Type", "application/json")] body
|
||||||
|
|
||||||
-- Static file + SPA fallback
|
throwJSON :: (A.ToJSON a) => HTTP.Status -> a -> IO b
|
||||||
|
throwJSON status v = throwResp status (A.encode v)
|
||||||
|
|
||||||
-- | MIME type lookup by file extension.
|
throwError :: HTTP.Status -> T.Text -> IO a
|
||||||
mimeType :: FilePath -> Maybe BS.ByteString
|
throwError status msg = throwJSON status (ErrorResponse msg Nothing)
|
||||||
mimeType path = Map.lookup (takeExtensionLower path) mimeTypes
|
|
||||||
|
throwFieldError :: HTTP.Status -> T.Text -> T.Text -> IO a
|
||||||
|
throwFieldError status msg field = throwJSON status (ErrorResponse msg (Just field))
|
||||||
|
|
||||||
|
-- | Parse JSON body.
|
||||||
|
parseBody :: (A.FromJSON a) => IO BL.ByteString -> IO a
|
||||||
|
parseBody getBody = do
|
||||||
|
body <- getBody
|
||||||
|
case A.decode body of
|
||||||
|
Just v -> pure v
|
||||||
|
Nothing -> throwError HTTP.status400 "Invalid JSON body"
|
||||||
|
|
||||||
|
-- | Get current user from session cookie.
|
||||||
|
getSessionUser :: SQL.Connection -> Wai.Request -> IO (Maybe User)
|
||||||
|
getSessionUser conn req = do
|
||||||
|
let cookies = parseCookies (Wai.requestHeaders req)
|
||||||
|
case Map.lookup Auth.sessionCookieName cookies of
|
||||||
|
Nothing -> pure Nothing
|
||||||
|
Just token -> do
|
||||||
|
now <- Time.getCurrentTime
|
||||||
|
result <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT u.id, u.display_name, u.email, u.password_hash \
|
||||||
|
\ FROM users u JOIN sessions s ON s.user_id = u.id \
|
||||||
|
\ WHERE s.token = ? AND s.expires_at > ?"
|
||||||
|
(token, now) ::
|
||||||
|
IO [(Int, T.Text, T.Text, T.Text)]
|
||||||
|
pure $ listToMaybe [User (UserId uid) name email pwHash | (uid, name, email, pwHash) <- result]
|
||||||
|
|
||||||
|
-- | Require authentication.
|
||||||
|
requireAuth :: SQL.Connection -> Wai.Request -> IO User
|
||||||
|
requireAuth conn req = do
|
||||||
|
mUser <- getSessionUser conn req
|
||||||
|
case mUser of
|
||||||
|
Just u -> pure u
|
||||||
|
Nothing -> throwError HTTP.status401 "Authentication required"
|
||||||
|
|
||||||
|
-- | Check household membership.
|
||||||
|
requireHouseholdRole :: SQL.Connection -> UserId -> Int -> IO T.Text
|
||||||
|
requireHouseholdRole conn userId hid = do
|
||||||
|
result <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT role FROM memberships WHERE household_id = ? AND user_id = ?"
|
||||||
|
(hid, unUserId userId) ::
|
||||||
|
IO [Only String]
|
||||||
|
case listToMaybe [T.pack role | Only role <- result] of
|
||||||
|
Just r -> pure r
|
||||||
|
Nothing -> throwError HTTP.status403 "Not a member of this household"
|
||||||
|
|
||||||
|
-- | Simple cookie parser.
|
||||||
|
parseCookies :: [HTTP.Header] -> Map.Map T.Text T.Text
|
||||||
|
parseCookies headers =
|
||||||
|
case lookup "cookie" headers of
|
||||||
|
Just raw ->
|
||||||
|
let pairs = T.splitOn "; " (TE.decodeUtf8 raw)
|
||||||
|
in Map.fromList [(T.strip k, T.strip v) | kv <- pairs, let (k, v') = T.breakOn "=" kv, not (T.null k), let v = T.drop 1 v']
|
||||||
|
Nothing -> Map.empty
|
||||||
|
|
||||||
|
-- | Set session cookie header.
|
||||||
|
mkSessionCookie :: T.Text -> Bool -> HTTP.Header
|
||||||
|
mkSessionCookie token rememberMe =
|
||||||
|
let maxAge = if rememberMe then (30 :: Int) * 86400 else 86400
|
||||||
|
in ("Set-Cookie", TE.encodeUtf8 Auth.sessionCookieName <> "=" <> TE.encodeUtf8 token <> "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" <> TE.encodeUtf8 (T.pack $ show maxAge))
|
||||||
|
|
||||||
|
clearSessionCookie :: HTTP.Header
|
||||||
|
clearSessionCookie = ("Set-Cookie", TE.encodeUtf8 Auth.sessionCookieName <> "=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")
|
||||||
|
|
||||||
|
-- | Get user's households.
|
||||||
|
getUserHouseholds :: SQL.Connection -> UserId -> IO [Household]
|
||||||
|
getUserHouseholds conn userId = do
|
||||||
|
rows <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT h.id, h.name, m2.user_id, \
|
||||||
|
\ (SELECT COUNT(*) FROM memberships WHERE household_id = h.id) \
|
||||||
|
\ FROM households h JOIN memberships m ON m.household_id = h.id AND m.user_id = ? \
|
||||||
|
\ JOIN memberships m2 ON m2.household_id = h.id AND m2.role = 'owner'"
|
||||||
|
(Only (unUserId userId)) ::
|
||||||
|
IO [(Int, T.Text, Int, Int)]
|
||||||
|
pure [Household (HouseholdId hId) hName (UserId ownerId) count | (hId, hName, ownerId, count) <- rows]
|
||||||
|
|
||||||
|
-- | Occurrence generation helpers.
|
||||||
|
generateOccurrences :: SQL.Connection -> Chore -> IO ()
|
||||||
|
generateOccurrences conn chore = do
|
||||||
|
today <- Time.utctDay <$> Time.getCurrentTime
|
||||||
|
let windowEnd = Time.addDays 90 today
|
||||||
|
case choreSchedule chore of
|
||||||
|
ScheduleOneOff date _ ->
|
||||||
|
when (date >= today && date <= windowEnd) $
|
||||||
|
SQL.execute conn "INSERT OR IGNORE INTO occurrences (chore_id, due_date) VALUES (?, ?)" (unChoreId (choreId chore), date)
|
||||||
|
ScheduleRecurring period startDate _ _ _ -> do
|
||||||
|
let dates = generateRecurringDates period startDate today windowEnd
|
||||||
|
mapM_ (\d -> SQL.execute conn "INSERT OR IGNORE INTO occurrences (chore_id, due_date) VALUES (?, ?)" (unChoreId (choreId chore), d)) dates
|
||||||
|
ScheduleSometime ->
|
||||||
|
SQL.execute conn "INSERT OR IGNORE INTO occurrences (chore_id, due_date) VALUES (?, '9999-12-31')" (Only (unChoreId (choreId chore)))
|
||||||
|
|
||||||
|
generateRecurringDates :: SchedulePeriod -> Time.Day -> Time.Day -> Time.Day -> [Time.Day]
|
||||||
|
generateRecurringDates period startDate fromDate toDate = go (max startDate fromDate)
|
||||||
where
|
where
|
||||||
takeExtensionLower p =
|
go d | d > toDate = [] | otherwise = d : go (next period d)
|
||||||
let ext = reverse $ takeWhile (/= '.') $ reverse p
|
next PeriodDaily = Time.addDays 1; next PeriodWeekly = Time.addDays 7; next PeriodMonthly = Time.addGregorianMonthsClip 1
|
||||||
in T.toLower $ T.pack ext
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Main router
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
newtype EmailOnly = EmailOnly T.Text
|
||||||
|
instance A.FromJSON EmailOnly where
|
||||||
|
parseJSON = A.withObject "EmailOnly" $ \o -> EmailOnly <$> o A..: "email"
|
||||||
|
|
||||||
|
data ResetPasswordRequest = ResetPasswordRequest {rprToken :: T.Text, rprPassword :: T.Text}
|
||||||
|
instance A.FromJSON ResetPasswordRequest where
|
||||||
|
parseJSON = A.withObject "ResetPasswordRequest" $ \o ->
|
||||||
|
ResetPasswordRequest <$> o A..: "token" <*> o A..: "password"
|
||||||
|
|
||||||
|
routeApi :: SQL.Connection -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> [T.Text] -> HTTP.Method -> IO BL.ByteString -> IO ()
|
||||||
|
routeApi conn req respond segs method getBody = case segs of
|
||||||
|
-- Health
|
||||||
|
("api" : "health" : _) ->
|
||||||
|
throwJSON HTTP.status200 (A.object ["status" A..= A.String "ok"])
|
||||||
|
-- Auth
|
||||||
|
("api" : "auth" : "signup" : _) -> handleSignup conn getBody respond
|
||||||
|
("api" : "auth" : "login" : _) -> handleLogin conn getBody respond
|
||||||
|
("api" : "auth" : "logout" : _) -> handleLogout conn req respond
|
||||||
|
("api" : "auth" : "me" : _) -> handleMe conn req
|
||||||
|
("api" : "auth" : "forgot-password" : _) -> handleForgotPassword conn getBody
|
||||||
|
("api" : "auth" : "reset-password" : _) -> handleResetPassword conn getBody
|
||||||
|
-- Households
|
||||||
|
["api", "households"]
|
||||||
|
| method == HTTP.methodGet -> handleListHouseholds conn req
|
||||||
|
| method == HTTP.methodPost -> handleCreateHousehold conn req getBody
|
||||||
|
| otherwise -> throwError HTTP.status405 "Method not allowed"
|
||||||
|
("api" : "households" : hidStr : rest) ->
|
||||||
|
case readMaybe (T.unpack hidStr) of
|
||||||
|
Just hid -> routeHousehold conn req respond hid rest method getBody
|
||||||
|
Nothing -> throwError HTTP.status400 "Invalid household ID"
|
||||||
|
-- Invites (top-level)
|
||||||
|
["api", "invites", code] -> handleLookupInvite conn code
|
||||||
|
("api" : "invites" : code : "accept" : _) -> handleAcceptInvite conn req code
|
||||||
|
-- Occurrences
|
||||||
|
("api" : "occurrences" : oidStr : "activity" : _) ->
|
||||||
|
case readMaybe (T.unpack oidStr) of
|
||||||
|
Just oid -> handleRecordActivity conn req oid getBody
|
||||||
|
Nothing -> throwError HTTP.status400 "Invalid occurrence ID"
|
||||||
|
-- Seed
|
||||||
|
("api" : "seed" : _) -> handleSeed conn
|
||||||
|
_ -> throwError HTTP.status404 "API route not found"
|
||||||
|
|
||||||
|
routeHousehold :: SQL.Connection -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> Int -> [T.Text] -> HTTP.Method -> IO BL.ByteString -> IO ()
|
||||||
|
routeHousehold conn req _respond hid rest method getBody = case rest of
|
||||||
|
[] -> case method of
|
||||||
|
_ | method == HTTP.methodGet -> handleGetHousehold conn req hid
|
||||||
|
_ | method == HTTP.methodPut -> handleUpdateHousehold conn req hid getBody
|
||||||
|
_ | method == HTTP.methodDelete -> handleDeleteHousehold conn req hid
|
||||||
|
_ -> throwError HTTP.status405 "Method not allowed"
|
||||||
|
["members"] -> handleListMembers conn req hid
|
||||||
|
["members", uidStr] ->
|
||||||
|
case readMaybe (T.unpack uidStr) of
|
||||||
|
Just uid -> handleRemoveMember conn req hid uid
|
||||||
|
Nothing -> throwError HTTP.status400 "Invalid user ID"
|
||||||
|
["invites"] -> handleListInvites conn req hid
|
||||||
|
["invites", iidStr] ->
|
||||||
|
case readMaybe (T.unpack iidStr) of
|
||||||
|
Just iid
|
||||||
|
| method == HTTP.methodDelete -> handleRevokeInvite conn req hid iid
|
||||||
|
| otherwise -> handleCreateInvite conn req hid getBody
|
||||||
|
Nothing -> throwError HTTP.status400 "Invalid invite ID"
|
||||||
|
["chores"] -> handleListChores conn req hid
|
||||||
|
["chores", cidStr] ->
|
||||||
|
case readMaybe (T.unpack cidStr) of
|
||||||
|
Just cid
|
||||||
|
| method == HTTP.methodPut -> handleUpdateChore conn req hid cid getBody
|
||||||
|
| method == HTTP.methodDelete -> handleDeleteChore conn req hid cid
|
||||||
|
| otherwise -> handleCreateChore conn req hid getBody
|
||||||
|
Nothing -> throwError HTTP.status400 "Invalid chore ID"
|
||||||
|
["dashboard"] -> handleDashboard conn req hid
|
||||||
|
["activity"] -> handleActivityLog conn req hid
|
||||||
|
_ -> throwError HTTP.status404 "API route not found"
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Auth handlers
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
handleSignup :: SQL.Connection -> IO BL.ByteString -> (Wai.Response -> IO Wai.ResponseReceived) -> IO ()
|
||||||
|
handleSignup conn getBody respond = do
|
||||||
|
req <- parseBody getBody
|
||||||
|
when (T.length (srPassword req) < 8) $ throwFieldError HTTP.status400 "Password must be at least 8 characters" "password"
|
||||||
|
when (srPassword req /= srConfirmPassword req) $ throwFieldError HTTP.status400 "Passwords do not match" "confirmPassword"
|
||||||
|
unless (srAgreeTerms req) $ throwFieldError HTTP.status400 "You must agree to the terms" "agreeTerms"
|
||||||
|
existing <- SQL.query conn "SELECT id FROM users WHERE email = ?" (Only (srEmail req)) :: IO [Only Int]
|
||||||
|
unless (null existing) $ throwError HTTP.status409 "Email already registered"
|
||||||
|
pwHash <- Auth.hashPassword (srPassword req)
|
||||||
|
SQL.execute conn "INSERT INTO users (display_name, email, password_hash) VALUES (?, ?, ?)" (srDisplayName req, srEmail req, pwHash)
|
||||||
|
uid <- SQL.lastInsertRowId conn
|
||||||
|
let userId = UserId (fromIntegral uid)
|
||||||
|
token <- Auth.generateToken
|
||||||
|
expires <- Time.addUTCTime 86400 <$> Time.getCurrentTime
|
||||||
|
SQL.execute conn "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)" (token, unUserId userId, expires)
|
||||||
|
let user = UserPublic userId (srDisplayName req) (srEmail req)
|
||||||
|
void $
|
||||||
|
respond $
|
||||||
|
Wai.responseLBS
|
||||||
|
HTTP.status201
|
||||||
|
[("Content-Type", "application/json"), mkSessionCookie token False]
|
||||||
|
(A.encode $ AuthResponse user [])
|
||||||
|
|
||||||
|
handleLogin :: SQL.Connection -> IO BL.ByteString -> (Wai.Response -> IO Wai.ResponseReceived) -> IO ()
|
||||||
|
handleLogin conn getBody respond = do
|
||||||
|
req <- parseBody getBody
|
||||||
|
result <- SQL.query conn "SELECT id, display_name, email, password_hash FROM users WHERE email = ?" (Only (lrEmail req)) :: IO [(Int, T.Text, T.Text, T.Text)]
|
||||||
|
case result of
|
||||||
|
[(uid, name, email, pwHash)] ->
|
||||||
|
if Auth.verifyPassword (lrPassword req) pwHash
|
||||||
|
then do
|
||||||
|
let userId = UserId uid
|
||||||
|
token <- Auth.generateToken
|
||||||
|
let maxAge = if lrRememberMe req then 30 * 86400 else 86400
|
||||||
|
expires <- Time.addUTCTime (fromIntegral (maxAge :: Int)) <$> Time.getCurrentTime
|
||||||
|
SQL.execute conn "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)" (token, unUserId userId, expires)
|
||||||
|
households <- getUserHouseholds conn userId
|
||||||
|
let user = UserPublic userId name email
|
||||||
|
void $
|
||||||
|
respond $
|
||||||
|
Wai.responseLBS
|
||||||
|
HTTP.status200
|
||||||
|
[("Content-Type", "application/json"), mkSessionCookie token (lrRememberMe req)]
|
||||||
|
(A.encode $ AuthResponse user households)
|
||||||
|
else throwError HTTP.status401 "Invalid email or password"
|
||||||
|
_ -> throwError HTTP.status401 "Invalid email or password"
|
||||||
|
|
||||||
|
handleLogout :: SQL.Connection -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO ()
|
||||||
|
handleLogout conn req respond = do
|
||||||
|
let cookies = parseCookies (Wai.requestHeaders req)
|
||||||
|
case Map.lookup Auth.sessionCookieName cookies of
|
||||||
|
Just token -> SQL.execute conn "DELETE FROM sessions WHERE token = ?" (Only token)
|
||||||
|
Nothing -> pure ()
|
||||||
|
void $
|
||||||
|
respond $
|
||||||
|
Wai.responseLBS
|
||||||
|
HTTP.status200
|
||||||
|
[("Content-Type", "application/json"), clearSessionCookie]
|
||||||
|
(A.encode $ A.object ["status" A..= A.String "logged_out"])
|
||||||
|
|
||||||
|
handleMe :: SQL.Connection -> Wai.Request -> IO ()
|
||||||
|
handleMe conn req = do
|
||||||
|
mUser <- getSessionUser conn req
|
||||||
|
case mUser of
|
||||||
|
Nothing -> throwError HTTP.status401 "Not authenticated"
|
||||||
|
Just (User uid name email _) -> do
|
||||||
|
households <- getUserHouseholds conn uid
|
||||||
|
let user = UserPublic uid name email
|
||||||
|
throwJSON HTTP.status200 $ AuthResponse user households
|
||||||
|
|
||||||
|
handleForgotPassword :: SQL.Connection -> IO BL.ByteString -> IO ()
|
||||||
|
handleForgotPassword conn getBody = do
|
||||||
|
req <- parseBody getBody
|
||||||
|
let EmailOnly email = req
|
||||||
|
result <- SQL.query conn "SELECT id FROM users WHERE email = ?" (Only email) :: IO [Only Int]
|
||||||
|
case result of
|
||||||
|
[Only uid] -> do
|
||||||
|
token <- Auth.generateToken
|
||||||
|
expires <- Time.addUTCTime 3600 <$> Time.getCurrentTime
|
||||||
|
SQL.execute conn "INSERT INTO reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)" (uid, token, expires)
|
||||||
|
_ -> pure ()
|
||||||
|
throwJSON HTTP.status200 (A.object ["status" A..= A.String "reset_sent"])
|
||||||
|
|
||||||
|
handleResetPassword :: SQL.Connection -> IO BL.ByteString -> IO ()
|
||||||
|
handleResetPassword conn getBody = do
|
||||||
|
req <- parseBody getBody
|
||||||
|
when (T.length (rprPassword req) < 8) $ throwError HTTP.status400 "Password must be at least 8 characters"
|
||||||
|
now <- Time.getCurrentTime
|
||||||
|
result <- SQL.query conn "SELECT user_id FROM reset_tokens WHERE token = ? AND used = 0 AND expires_at > ?" (rprToken req, now) :: IO [Only Int]
|
||||||
|
case result of
|
||||||
|
[Only uid] -> do
|
||||||
|
pwHash <- Auth.hashPassword (rprPassword req)
|
||||||
|
SQL.execute conn "UPDATE users SET password_hash = ? WHERE id = ?" (pwHash, uid)
|
||||||
|
SQL.execute conn "UPDATE reset_tokens SET used = 1 WHERE token = ?" (Only (rprToken req))
|
||||||
|
throwJSON HTTP.status200 (A.object ["status" A..= A.String "password_reset"])
|
||||||
|
_ -> throwError HTTP.status400 "Invalid or expired reset token"
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Household handlers
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
handleCreateHousehold :: SQL.Connection -> Wai.Request -> IO BL.ByteString -> IO ()
|
||||||
|
handleCreateHousehold conn req getBody = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
chr <- parseBody getBody
|
||||||
|
SQL.execute conn "INSERT INTO households (name) VALUES (?)" (Only (chrName chr))
|
||||||
|
hId <- SQL.lastInsertRowId conn
|
||||||
|
let hid = HouseholdId (fromIntegral hId)
|
||||||
|
SQL.execute conn "INSERT INTO memberships (household_id, user_id, role) VALUES (?, ?, ?)" (unHouseholdId hid, unUserId (userId user), "owner" :: String)
|
||||||
|
throwJSON HTTP.status201 $ Household hid (chrName chr) (userId user) 1
|
||||||
|
|
||||||
|
handleListHouseholds :: SQL.Connection -> Wai.Request -> IO ()
|
||||||
|
handleListHouseholds conn req = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
hs <- getUserHouseholds conn (userId user)
|
||||||
|
throwJSON HTTP.status200 hs
|
||||||
|
|
||||||
|
handleGetHousehold :: SQL.Connection -> Wai.Request -> Int -> IO ()
|
||||||
|
handleGetHousehold conn req hid = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
_ <- requireHouseholdRole conn (userId user) hid
|
||||||
|
result <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT h.id, h.name, m2.user_id, (SELECT COUNT(*) FROM memberships WHERE household_id = h.id) \
|
||||||
|
\ FROM households h JOIN memberships m ON m.household_id = h.id AND m.user_id = ? \
|
||||||
|
\ JOIN memberships m2 ON m2.household_id = h.id AND m2.role = 'owner' WHERE h.id = ?"
|
||||||
|
(unUserId (userId user), hid) ::
|
||||||
|
IO [(Int, T.Text, Int, Int)]
|
||||||
|
case result of
|
||||||
|
[(hId, hName, ownerId, count)] -> throwJSON HTTP.status200 $ Household (HouseholdId hId) hName (UserId ownerId) count
|
||||||
|
_ -> throwError HTTP.status404 "Household not found"
|
||||||
|
|
||||||
|
handleUpdateHousehold :: SQL.Connection -> Wai.Request -> Int -> IO BL.ByteString -> IO ()
|
||||||
|
handleUpdateHousehold conn req hid getBody = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
role <- requireHouseholdRole conn (userId user) hid
|
||||||
|
unless (role == "owner") $ throwError HTTP.status403 "Only the owner can rename the household"
|
||||||
|
chr <- parseBody getBody
|
||||||
|
SQL.execute conn "UPDATE households SET name = ? WHERE id = ?" (chrName chr, hid)
|
||||||
|
result <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT h.id, h.name, m.user_id, (SELECT COUNT(*) FROM memberships WHERE household_id = h.id) \
|
||||||
|
\ FROM households h JOIN memberships m ON m.household_id = h.id AND m.role = 'owner' WHERE h.id = ?"
|
||||||
|
(Only hid) ::
|
||||||
|
IO [(Int, T.Text, Int, Int)]
|
||||||
|
case result of
|
||||||
|
[(hId, hName, ownerId, count)] -> throwJSON HTTP.status200 $ Household (HouseholdId hId) hName (UserId ownerId) count
|
||||||
|
_ -> throwError HTTP.status404 "Household not found"
|
||||||
|
|
||||||
|
handleDeleteHousehold :: SQL.Connection -> Wai.Request -> Int -> IO ()
|
||||||
|
handleDeleteHousehold conn req hid = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
role <- requireHouseholdRole conn (userId user) hid
|
||||||
|
unless (role == "owner") $ throwError HTTP.status403 "Only the owner can delete the household"
|
||||||
|
SQL.execute conn "DELETE FROM households WHERE id = ?" (Only hid)
|
||||||
|
throwJSON HTTP.status200 (A.object ["status" A..= A.String "deleted"])
|
||||||
|
|
||||||
|
handleListMembers :: SQL.Connection -> Wai.Request -> Int -> IO ()
|
||||||
|
handleListMembers conn req hid = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
_ <- requireHouseholdRole conn (userId user) hid
|
||||||
|
members <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT u.id, u.display_name, u.email, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.household_id = ?"
|
||||||
|
(Only hid) ::
|
||||||
|
IO [(Int, T.Text, T.Text, String)]
|
||||||
|
throwJSON HTTP.status200 [Membership (UserId uid) dname email (if role == "owner" then OwnerRole else MemberRole) | (uid, dname, email, role) <- members]
|
||||||
|
|
||||||
|
handleRemoveMember :: SQL.Connection -> Wai.Request -> Int -> Int -> IO ()
|
||||||
|
handleRemoveMember conn req hid targetUid = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
role <- requireHouseholdRole conn (userId user) hid
|
||||||
|
unless (role == "owner") $ throwError HTTP.status403 "Only the owner can remove members"
|
||||||
|
SQL.execute conn "DELETE FROM memberships WHERE household_id = ? AND user_id = ?" (hid, targetUid)
|
||||||
|
throwJSON HTTP.status200 (A.object ["status" A..= A.String "removed"])
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Invite handlers
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
handleCreateInvite :: SQL.Connection -> Wai.Request -> Int -> IO BL.ByteString -> IO ()
|
||||||
|
handleCreateInvite conn req hid getBody = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
role <- requireHouseholdRole conn (userId user) hid
|
||||||
|
unless (role == "owner") $ throwError HTTP.status403 "Only the owner can invite members"
|
||||||
|
cir <- parseBody getBody
|
||||||
|
case cirEmail cir of
|
||||||
|
Just em -> do
|
||||||
|
existing <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT 1 FROM users u JOIN memberships m ON m.user_id = u.id WHERE u.email = ? AND m.household_id = ?"
|
||||||
|
(em, hid) ::
|
||||||
|
IO [Only Int]
|
||||||
|
unless (null existing) $ throwError HTTP.status409 "User is already a member"
|
||||||
|
Nothing -> pure ()
|
||||||
|
code <- Auth.generateToken
|
||||||
|
now <- Time.getCurrentTime
|
||||||
|
SQL.execute conn "INSERT INTO invites (household_id, code, email, created_at) VALUES (?, ?, ?, ?)" (hid, code, cirEmail cir, now)
|
||||||
|
iid <- SQL.lastInsertRowId conn
|
||||||
|
throwJSON HTTP.status201 $ Invite (InviteId (fromIntegral iid)) (HouseholdId hid) code (cirEmail cir) InvitePending now
|
||||||
|
|
||||||
|
handleListInvites :: SQL.Connection -> Wai.Request -> Int -> IO ()
|
||||||
|
handleListInvites conn req hid = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
_ <- requireHouseholdRole conn (userId user) hid
|
||||||
|
invites <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT id, household_id, code, email, status, created_at FROM invites WHERE household_id = ?"
|
||||||
|
(Only hid) ::
|
||||||
|
IO [(Int, Int, T.Text, Maybe T.Text, T.Text, Time.UTCTime)]
|
||||||
|
throwJSON HTTP.status200 [Invite (InviteId iid) (HouseholdId hhid) code email (mkStatus st) createdAt | (iid, hhid, code, email, st, createdAt) <- invites]
|
||||||
|
where
|
||||||
|
mkStatus "pending" = InvitePending; mkStatus "accepted" = InviteAccepted; mkStatus _ = InviteRevoked
|
||||||
|
|
||||||
|
handleRevokeInvite :: SQL.Connection -> Wai.Request -> Int -> Int -> IO ()
|
||||||
|
handleRevokeInvite conn req hid iid = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
role <- requireHouseholdRole conn (userId user) hid
|
||||||
|
unless (role == "owner") $ throwError HTTP.status403 "Only the owner can revoke invites"
|
||||||
|
SQL.execute conn "UPDATE invites SET status = 'revoked' WHERE id = ? AND household_id = ?" (iid, hid)
|
||||||
|
throwJSON HTTP.status200 (A.object ["status" A..= A.String "revoked"])
|
||||||
|
|
||||||
|
handleLookupInvite :: SQL.Connection -> T.Text -> IO ()
|
||||||
|
handleLookupInvite conn code = do
|
||||||
|
result <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT id, household_id, code, email, status, created_at FROM invites WHERE code = ? AND status = 'pending'"
|
||||||
|
(Only code) ::
|
||||||
|
IO [(Int, Int, T.Text, Maybe T.Text, T.Text, Time.UTCTime)]
|
||||||
|
case result of
|
||||||
|
[(iid, hhid, c, email, _, createdAt)] -> throwJSON HTTP.status200 $ Invite (InviteId iid) (HouseholdId hhid) c email InvitePending createdAt
|
||||||
|
_ -> throwError HTTP.status404 "Invite not found or already used"
|
||||||
|
|
||||||
|
handleAcceptInvite :: SQL.Connection -> Wai.Request -> T.Text -> IO ()
|
||||||
|
handleAcceptInvite conn req code = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
result <- SQL.query conn "SELECT id, household_id FROM invites WHERE code = ? AND status = 'pending'" (Only code) :: IO [(Int, Int)]
|
||||||
|
case result of
|
||||||
|
[(iid, hid)] -> do
|
||||||
|
existing <- SQL.query conn "SELECT 1 FROM memberships WHERE household_id = ? AND user_id = ?" (hid, unUserId (userId user)) :: IO [Only Int]
|
||||||
|
unless (null existing) $ throwError HTTP.status409 "Already a member"
|
||||||
|
SQL.execute conn "INSERT INTO memberships (household_id, user_id, role) VALUES (?, ?, ?)" (hid, unUserId (userId user), "member" :: String)
|
||||||
|
SQL.execute conn "UPDATE invites SET status = 'accepted' WHERE id = ?" (Only iid)
|
||||||
|
hResult <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT h.id, h.name, m.user_id, (SELECT COUNT(*) FROM memberships WHERE household_id = h.id) \
|
||||||
|
\ FROM households h JOIN memberships m ON m.household_id = h.id AND m.role = 'owner' WHERE h.id = ?"
|
||||||
|
(Only hid) ::
|
||||||
|
IO [(Int, T.Text, Int, Int)]
|
||||||
|
case hResult of
|
||||||
|
[(hId, hName, ownerId, count)] -> throwJSON HTTP.status200 $ Household (HouseholdId hId) hName (UserId ownerId) count
|
||||||
|
_ -> throwError HTTP.status404 "Household not found"
|
||||||
|
_ -> throwError HTTP.status404 "Invite not found"
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Chore handlers
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
handleListChores :: SQL.Connection -> Wai.Request -> Int -> IO ()
|
||||||
|
handleListChores conn req hid = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
_ <- requireHouseholdRole conn (userId user) hid
|
||||||
|
chores <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT id, household_id, name, assignee_type, assignee_user_id, schedule_data, notify_on_due, created_at \
|
||||||
|
\ FROM chores WHERE household_id = ?"
|
||||||
|
(Only hid) ::
|
||||||
|
IO [(Int, Int, T.Text, String, Maybe Int, T.Text, Int, Time.UTCTime)]
|
||||||
|
throwJSON
|
||||||
|
HTTP.status200
|
||||||
|
[ Chore (ChoreId cid) (HouseholdId hId) cname (mkAssignee atype auid) (mkSchedule sData) (nud /= 0) createdAt
|
||||||
|
| (cid, hId, cname, atype, auid, sData, nud, createdAt) <- chores
|
||||||
|
]
|
||||||
|
where
|
||||||
|
mkAssignee "user" (Just uid) = AssigneeUser (UserId uid); mkAssignee _ _ = AssigneeAnyone
|
||||||
|
mkSchedule sData = fromMaybe ScheduleSometime (A.decodeStrict (TE.encodeUtf8 sData))
|
||||||
|
|
||||||
|
handleCreateChore :: SQL.Connection -> Wai.Request -> Int -> IO BL.ByteString -> IO ()
|
||||||
|
handleCreateChore conn req hid getBody = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
_ <- requireHouseholdRole conn (userId user) hid
|
||||||
|
ccr <- parseBody getBody
|
||||||
|
now <- Time.getCurrentTime
|
||||||
|
let (aType, aUid) = case ccrAssignee ccr of AssigneeUser (UserId uid) -> ("user" :: String, Just uid); AssigneeAnyone -> ("anyone", Nothing)
|
||||||
|
sType = case ccrSchedule ccr of ScheduleOneOff{} -> "one_off" :: String; ScheduleRecurring{} -> "recurring"; ScheduleSometime -> "sometime"
|
||||||
|
sData = TE.decodeUtf8 $ BL.toStrict $ A.encode (ccrSchedule ccr)
|
||||||
|
SQL.execute
|
||||||
|
conn
|
||||||
|
"INSERT INTO chores (household_id, name, assignee_type, assignee_user_id, schedule_type, schedule_data, notify_on_due, created_at) VALUES (?,?,?,?,?,?,?,?)"
|
||||||
|
(hid, ccrName ccr, aType, aUid, sType, sData, if ccrNotifyOnDue ccr then 1 :: Int else 0, now)
|
||||||
|
cId <- SQL.lastInsertRowId conn
|
||||||
|
let chore = Chore (ChoreId (fromIntegral cId)) (HouseholdId hid) (ccrName ccr) (ccrAssignee ccr) (ccrSchedule ccr) (ccrNotifyOnDue ccr) now
|
||||||
|
generateOccurrences conn chore
|
||||||
|
throwJSON HTTP.status201 chore
|
||||||
|
|
||||||
|
handleUpdateChore :: SQL.Connection -> Wai.Request -> Int -> Int -> IO BL.ByteString -> IO ()
|
||||||
|
handleUpdateChore conn req hid cid getBody = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
_ <- requireHouseholdRole conn (userId user) hid
|
||||||
|
ucr <- parseBody getBody
|
||||||
|
now <- Time.getCurrentTime
|
||||||
|
let (aType, aUid) = case ucrAssignee ucr of AssigneeUser (UserId uid) -> ("user" :: String, Just uid); AssigneeAnyone -> ("anyone", Nothing)
|
||||||
|
sType = case ucrSchedule ucr of ScheduleOneOff{} -> "one_off" :: String; ScheduleRecurring{} -> "recurring"; ScheduleSometime -> "sometime"
|
||||||
|
sData = TE.decodeUtf8 $ BL.toStrict $ A.encode (ucrSchedule ucr)
|
||||||
|
SQL.execute
|
||||||
|
conn
|
||||||
|
"UPDATE chores SET name=?, assignee_type=?, assignee_user_id=?, schedule_type=?, schedule_data=?, notify_on_due=? WHERE id=? AND household_id=?"
|
||||||
|
(ucrName ucr, aType, aUid, sType, sData, if ucrNotifyOnDue ucr then 1 :: Int else 0, cid, hid)
|
||||||
|
SQL.execute conn "DELETE FROM occurrences WHERE chore_id = ? AND status IN ('due', 'overdue')" (Only cid)
|
||||||
|
let chore = Chore (ChoreId cid) (HouseholdId hid) (ucrName ucr) (ucrAssignee ucr) (ucrSchedule ucr) (ucrNotifyOnDue ucr) now
|
||||||
|
generateOccurrences conn chore
|
||||||
|
throwJSON HTTP.status200 chore
|
||||||
|
|
||||||
|
handleDeleteChore :: SQL.Connection -> Wai.Request -> Int -> Int -> IO ()
|
||||||
|
handleDeleteChore conn req hid cid = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
_ <- requireHouseholdRole conn (userId user) hid
|
||||||
|
SQL.execute conn "DELETE FROM chores WHERE id = ? AND household_id = ?" (cid, hid)
|
||||||
|
throwJSON HTTP.status200 (A.object ["status" A..= A.String "deleted"])
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Dashboard
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
handleDashboard :: SQL.Connection -> Wai.Request -> Int -> IO ()
|
||||||
|
handleDashboard conn req hid = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
_ <- requireHouseholdRole conn (userId user) hid
|
||||||
|
today <- Time.utctDay <$> Time.getCurrentTime
|
||||||
|
let todayStr = show today
|
||||||
|
[Only overdueCount] <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT COUNT(*) FROM occurrences o JOIN chores c ON c.id = o.chore_id WHERE c.household_id = ? AND o.due_date < ? AND o.status IN ('due', 'overdue')"
|
||||||
|
(hid, todayStr) ::
|
||||||
|
IO [Only Int]
|
||||||
|
[Only dueTodayCount] <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT COUNT(*) FROM occurrences o JOIN chores c ON c.id = o.chore_id WHERE c.household_id = ? AND o.due_date = ? AND o.status = 'due'"
|
||||||
|
(hid, todayStr) ::
|
||||||
|
IO [Only Int]
|
||||||
|
[Only doneThisWeek] <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT COUNT(*) FROM activities a JOIN occurrences o ON o.id = a.occurrence_id JOIN chores c ON c.id = o.chore_id WHERE c.household_id = ? AND a.recorded_at >= ?"
|
||||||
|
(hid, show (Time.addDays (-7) today)) ::
|
||||||
|
IO [Only Int]
|
||||||
|
let stats = DashboardStats overdueCount dueTodayCount doneThisWeek
|
||||||
|
dueRows <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT o.id, o.chore_id, o.due_date, o.status, c.name, u.display_name \
|
||||||
|
\ FROM occurrences o JOIN chores c ON c.id = o.chore_id LEFT JOIN users u ON u.id = c.assignee_user_id \
|
||||||
|
\ WHERE c.household_id = ? AND o.due_date <= ? AND o.status IN ('due', 'overdue') ORDER BY o.due_date LIMIT 50"
|
||||||
|
(hid, todayStr) ::
|
||||||
|
IO [(Int, Int, Time.Day, T.Text, T.Text, Maybe T.Text)]
|
||||||
|
let dueItems = [DueItem (Occurrence (OccurrenceId oid) (ChoreId cid) d (mkOcc st)) cn uname (d < today) | (oid, cid, d, st, cn, uname) <- dueRows]
|
||||||
|
compRows <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT a.id, a.occurrence_id, a.user_id, a.status, a.note, a.notify_household, a.recorded_at, u.display_name, c.name \
|
||||||
|
\ FROM activities a JOIN occurrences o ON o.id = a.occurrence_id JOIN chores c ON c.id = o.chore_id JOIN users u ON u.id = a.user_id \
|
||||||
|
\ WHERE c.household_id = ? AND a.recorded_at >= ? ORDER BY a.recorded_at DESC LIMIT 50"
|
||||||
|
(hid, show today) ::
|
||||||
|
IO [(Int, Int, Int, T.Text, Maybe T.Text, Int, Time.UTCTime, T.Text, T.Text)]
|
||||||
|
let compItems =
|
||||||
|
[ CompletedItem (Activity (ActivityId aid) (OccurrenceId oid) (UserId uid) (mkAct st) note (nh /= 0) recAt) uname cn
|
||||||
|
| (aid, oid, uid, st, note, nh, recAt, uname, cn) <- compRows
|
||||||
|
]
|
||||||
|
throwJSON HTTP.status200 $ Dashboard stats dueItems compItems
|
||||||
|
where
|
||||||
|
mkOcc "due" = OccDue; mkOcc "overdue" = OccOverdue; mkOcc "completed" = OccCompleted; mkOcc _ = OccSkipped
|
||||||
|
mkAct "completed" = ActivityCompleted; mkAct _ = ActivitySkipped
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Activity
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
handleRecordActivity :: SQL.Connection -> Wai.Request -> Int -> IO BL.ByteString -> IO ()
|
||||||
|
handleRecordActivity conn req oid getBody = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
rar <- parseBody getBody
|
||||||
|
now <- Time.getCurrentTime
|
||||||
|
occResult <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT o.chore_id, c.household_id FROM occurrences o JOIN chores c ON c.id = o.chore_id WHERE o.id = ?"
|
||||||
|
(Only oid) ::
|
||||||
|
IO [(Int, Int)]
|
||||||
|
case occResult of
|
||||||
|
[(_, hid)] -> do
|
||||||
|
_ <- requireHouseholdRole conn (userId user) hid
|
||||||
|
let actStatus = case rarStatus rar of ActivityCompleted -> "completed" :: String; ActivitySkipped -> "skipped"
|
||||||
|
SQL.execute
|
||||||
|
conn
|
||||||
|
"INSERT INTO activities (occurrence_id, user_id, status, note, notify_household, recorded_at) VALUES (?,?,?,?,?,?)"
|
||||||
|
(oid, unUserId (userId user), actStatus, rarNote rar, if rarNotifyHousehold rar then 1 :: Int else 0, now)
|
||||||
|
let occStatus = case rarStatus rar of ActivityCompleted -> "completed" :: String; ActivitySkipped -> "skipped"
|
||||||
|
SQL.execute conn "UPDATE occurrences SET status = ? WHERE id = ?" (occStatus, oid)
|
||||||
|
actId <- SQL.lastInsertRowId conn
|
||||||
|
throwJSON HTTP.status201 $ Activity (ActivityId (fromIntegral actId)) (OccurrenceId oid) (userId user) (rarStatus rar) (rarNote rar) (rarNotifyHousehold rar) now
|
||||||
|
_ -> throwError HTTP.status404 "Occurrence not found"
|
||||||
|
|
||||||
|
handleActivityLog :: SQL.Connection -> Wai.Request -> Int -> IO ()
|
||||||
|
handleActivityLog conn req hid = do
|
||||||
|
user <- requireAuth conn req
|
||||||
|
_ <- requireHouseholdRole conn (userId user) hid
|
||||||
|
let qs = Wai.queryString req
|
||||||
|
{- HLINT ignore "Use join" -}
|
||||||
|
let page = maybe 1 (read . T.unpack . TE.decodeUtf8) (lookup "page" qs >>= id)
|
||||||
|
let perPage = maybe 20 (read . T.unpack . TE.decodeUtf8) (lookup "perPage" qs >>= id)
|
||||||
|
let offset = (page - 1) * perPage
|
||||||
|
[Only totalCount] <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT COUNT(*) FROM activities a JOIN occurrences o ON o.id = a.occurrence_id JOIN chores c ON c.id = o.chore_id WHERE c.household_id = ?"
|
||||||
|
(Only hid) ::
|
||||||
|
IO [Only Int]
|
||||||
|
entries <-
|
||||||
|
SQL.query
|
||||||
|
conn
|
||||||
|
"SELECT a.id, a.occurrence_id, a.user_id, a.status, a.note, a.notify_household, a.recorded_at, u.display_name, c.name, o.due_date \
|
||||||
|
\ FROM activities a JOIN occurrences o ON o.id = a.occurrence_id JOIN chores c ON c.id = o.chore_id JOIN users u ON u.id = a.user_id \
|
||||||
|
\ WHERE c.household_id = ? ORDER BY a.recorded_at DESC LIMIT ? OFFSET ?"
|
||||||
|
(hid, perPage, offset) ::
|
||||||
|
IO [(Int, Int, Int, T.Text, Maybe T.Text, Int, Time.UTCTime, T.Text, T.Text, Time.Day)]
|
||||||
|
let logEntries =
|
||||||
|
[ ActivityLogEntry (Activity (ActivityId aid) (OccurrenceId oid) (UserId uid) (mkAct st) note (nh /= 0) recAt) uname "" cn d
|
||||||
|
| (aid, oid, uid, st, note, nh, recAt, uname, cn, d) <- entries
|
||||||
|
]
|
||||||
|
throwJSON HTTP.status200 $ ActivityLogPage logEntries page perPage totalCount
|
||||||
|
where
|
||||||
|
mkAct "completed" = ActivityCompleted; mkAct _ = ActivitySkipped
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Seed
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
handleSeed :: SQL.Connection -> IO ()
|
||||||
|
handleSeed conn = do
|
||||||
|
let demoPassword = "password123"
|
||||||
|
pwHash <- Auth.hashPassword demoPassword
|
||||||
|
SQL.execute conn "INSERT OR IGNORE INTO users (id, display_name, email, password_hash) VALUES (1, 'Alice', 'alice@demo.com', ?)" (Only pwHash)
|
||||||
|
SQL.execute conn "INSERT OR IGNORE INTO users (id, display_name, email, password_hash) VALUES (2, 'Bob', 'bob@demo.com', ?)" (Only pwHash)
|
||||||
|
SQL.execute conn "INSERT OR IGNORE INTO users (id, display_name, email, password_hash) VALUES (3, 'Charlie', 'charlie@demo.com', ?)" (Only pwHash)
|
||||||
|
SQL.execute_ conn "INSERT OR IGNORE INTO households (id, name) VALUES (1, 'Demo House')"
|
||||||
|
SQL.execute_ conn "INSERT OR IGNORE INTO memberships (household_id, user_id, role) VALUES (1, 1, 'owner')"
|
||||||
|
SQL.execute_ conn "INSERT OR IGNORE INTO memberships (household_id, user_id, role) VALUES (1, 2, 'member')"
|
||||||
|
SQL.execute_ conn "INSERT OR IGNORE INTO memberships (household_id, user_id, role) VALUES (1, 3, 'member')"
|
||||||
|
let sData1 = TE.decodeUtf8 $ BL.toStrict $ A.encode (ScheduleRecurring PeriodDaily (read "2026-07-15") (Just "08:00:00") Nothing Nothing)
|
||||||
|
SQL.execute conn "INSERT OR IGNORE INTO chores (id, household_id, name, assignee_type, schedule_type, schedule_data, notify_on_due) VALUES (1, 1, 'Take out trash', 'anyone', 'recurring', ?, 1)" (Only sData1)
|
||||||
|
let sData2 = TE.decodeUtf8 $ BL.toStrict $ A.encode (ScheduleRecurring PeriodWeekly (read "2026-07-13") (Just "10:00:00") (Just [1, 4]) Nothing)
|
||||||
|
SQL.execute conn "INSERT OR IGNORE INTO chores (id, household_id, name, assignee_type, assignee_user_id, schedule_type, schedule_data, notify_on_due) VALUES (2, 1, 'Vacuum living room', 'user', 2, 'recurring', ?, 0)" (Only sData2)
|
||||||
|
let sData3 = TE.decodeUtf8 $ BL.toStrict $ A.encode ScheduleSometime
|
||||||
|
SQL.execute conn "INSERT OR IGNORE INTO chores (id, household_id, name, assignee_type, schedule_type, schedule_data, notify_on_due) VALUES (3, 1, 'Clean the garage', 'anyone', 'sometime', ?, 0)" (Only sData3)
|
||||||
|
today <- Time.utctDay <$> Time.getCurrentTime
|
||||||
|
let windowEnd = Time.addDays 90 today
|
||||||
|
let dates1 = generateRecurringDates PeriodDaily (read "2026-07-15") today windowEnd
|
||||||
|
mapM_ (SQL.execute conn "INSERT OR IGNORE INTO occurrences (chore_id, due_date) VALUES (1, ?)" . Only) (take 90 dates1)
|
||||||
|
let dates2 = generateRecurringDates PeriodWeekly (read "2026-07-13") today windowEnd
|
||||||
|
mapM_ (SQL.execute conn "INSERT OR IGNORE INTO occurrences (chore_id, due_date) VALUES (2, ?)" . Only) (take 90 dates2)
|
||||||
|
SQL.execute_ conn "INSERT OR IGNORE INTO occurrences (chore_id, due_date) VALUES (3, '9999-12-31')"
|
||||||
|
throwJSON HTTP.status200 (A.object ["status" A..= A.String "seeded"])
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Static file serving
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
mimeType :: FilePath -> Maybe BS.ByteString
|
||||||
|
mimeType fp = Map.lookup ext mimeTypes
|
||||||
|
where
|
||||||
|
ext = T.toLower $ T.pack $ reverse $ takeWhile (/= '.') $ reverse fp
|
||||||
|
|
||||||
mimeTypes :: Map.Map T.Text BS.ByteString
|
mimeTypes :: Map.Map T.Text BS.ByteString
|
||||||
mimeTypes =
|
mimeTypes =
|
||||||
Map.fromList
|
Map.fromList
|
||||||
[ ("html", "text/html")
|
[ ("html", "text/html")
|
||||||
, ("css", "text/css")
|
, ("css", "text/css")
|
||||||
, ("js", "application/javascript")
|
, ("js", "application/javascript")
|
||||||
, ("json", "application/json")
|
, ("json", "application/json")
|
||||||
, ("png", "image/png")
|
, ("png", "image/png")
|
||||||
, ("svg", "image/svg+xml")
|
, ("jpg", "image/jpeg")
|
||||||
, ("ico", "image/x-icon")
|
, ("svg", "image/svg+xml")
|
||||||
, ("woff2", "font/woff2")
|
, ("ico", "image/x-icon")
|
||||||
]
|
, ("manifest", "application/manifest+json")
|
||||||
|
]
|
||||||
|
|
||||||
{- | Serve a static file from @staticDir@.
|
|
||||||
|
|
||||||
For paths without a file extension (SPA client-side routes), serves
|
|
||||||
@index.html@ instead so the SPA can handle routing.
|
|
||||||
|
|
||||||
Returns 'True' if a file was served, 'False' if nothing matched.
|
|
||||||
-}
|
|
||||||
serveStaticOrSpa :: FilePath -> Wai.Application
|
serveStaticOrSpa :: FilePath -> Wai.Application
|
||||||
serveStaticOrSpa staticDir request respond = do
|
serveStaticOrSpa staticDir request respond = do
|
||||||
let path = T.unpack $ TE.decodeUtf8 $ Wai.rawPathInfo request
|
let path = TE.decodeUtf8 $ Wai.rawPathInfo request
|
||||||
-- Drop leading slash for filesystem lookup.
|
let fp = staticDir </> dropWhile (== '/') (T.unpack path)
|
||||||
let relPath = case path of
|
exists <- doesFileExist fp
|
||||||
'/' : rest -> rest
|
let hasExt = '.' `elem` reverse (takeWhile (/= '/') (reverse (T.unpack path)))
|
||||||
other -> other
|
if exists
|
||||||
let candidate = if null relPath || not (hasExtension relPath)
|
then do
|
||||||
then "index.html"
|
content <- BS.readFile fp
|
||||||
else relPath
|
let ct = fromMaybe "application/octet-stream" $ mimeType fp
|
||||||
let filePath = staticDir </> candidate
|
respond $ Wai.responseLBS HTTP.status200 [("Content-Type", ct)] (BL.fromStrict content)
|
||||||
exists <- doesFileExist filePath
|
else
|
||||||
if exists
|
if not hasExt
|
||||||
then do
|
then do
|
||||||
let mime = maybe "application/octet-stream" id (mimeType candidate)
|
let indexPath = staticDir </> "index.html"
|
||||||
respond $ Wai.responseFile HTTP.status200 [("Content-Type", mime)] filePath Nothing
|
idxExists <- doesFileExist indexPath
|
||||||
else
|
if idxExists
|
||||||
respond notFoundResponse
|
then do
|
||||||
|
content <- BS.readFile indexPath
|
||||||
hasExtension :: FilePath -> Bool
|
respond $ Wai.responseLBS HTTP.status200 [("Content-Type", "text/html")] (BL.fromStrict content)
|
||||||
hasExtension = elem '.' . takeFileName
|
else respond notFoundResponse
|
||||||
|
else respond notFoundResponse
|
||||||
takeFileName :: FilePath -> FilePath
|
|
||||||
takeFileName = reverse . takeWhile (/= '/') . reverse
|
|
||||||
|
|
||||||
notFoundResponse :: Wai.Response
|
notFoundResponse :: Wai.Response
|
||||||
notFoundResponse =
|
notFoundResponse = Wai.responseLBS HTTP.status404 [("Content-Type", "text/plain")] "Not Found"
|
||||||
Wai.responseLBS HTTP.status404 [("Content-Type", "text/plain")] "Not Found"
|
|
||||||
|
|
||||||
-- Internal WAI dispatch monad
|
|
||||||
|
|
||||||
data SisDispatchEnv = SisDispatchEnv
|
|
||||||
{ sisRequest :: Wai.Request
|
|
||||||
, sisRespond :: Wai.Response -> IO Wai.ResponseReceived
|
|
||||||
}
|
|
||||||
|
|
||||||
newtype SisDispatchM a
|
|
||||||
= SisDispatchM (Reader.ReaderT SisDispatchEnv IO a)
|
|
||||||
deriving
|
|
||||||
( Functor
|
|
||||||
, Applicative
|
|
||||||
, Monad
|
|
||||||
, MIO.MonadIO
|
|
||||||
, Safe.MonadThrow
|
|
||||||
, Safe.MonadCatch
|
|
||||||
)
|
|
||||||
|
|
||||||
instance Orb.HasRequest SisDispatchM where
|
|
||||||
request = SisDispatchM (Reader.asks sisRequest)
|
|
||||||
|
|
||||||
instance Orb.HasRespond SisDispatchM where
|
|
||||||
respond = SisDispatchM (Reader.asks sisRespond)
|
|
||||||
|
|
||||||
instance Orb.HasLogger SisDispatchM where
|
|
||||||
log = MIO.liftIO . putStrLn . Safe.displayException
|
|
||||||
|
|
||||||
-- Health check route
|
|
||||||
|
|
||||||
{- | GET \/api\/health
|
|
||||||
|
|
||||||
Returns a simple health-check response.
|
|
||||||
-}
|
|
||||||
data HealthCheck = HealthCheck
|
|
||||||
|
|
||||||
instance Orb.HasHandler HealthCheck where
|
|
||||||
type HandlerResponses HealthCheck = HealthCheckResponses
|
|
||||||
type HandlerPermissionAction HealthCheck = NoPermissions
|
|
||||||
type HandlerMonad HealthCheck = SisDispatchM
|
|
||||||
|
|
||||||
routeHandler = healthCheckHandler
|
|
||||||
|
|
||||||
type HealthCheckResponses =
|
|
||||||
'[ Orb.Response200 Orb.SuccessMessage
|
|
||||||
, Orb.Response500 Orb.InternalServerError
|
|
||||||
]
|
|
||||||
|
|
||||||
healthCheckHandler :: Orb.Handler HealthCheck
|
|
||||||
healthCheckHandler =
|
|
||||||
Orb.Handler
|
|
||||||
{ Orb.handlerId = "healthCheck"
|
|
||||||
, Orb.requestBody = Orb.EmptyRequestBody
|
|
||||||
, Orb.requestQuery = Orb.EmptyRequestQuery
|
|
||||||
, Orb.requestHeaders = Orb.EmptyRequestHeaders
|
|
||||||
, Orb.handlerResponseBodies =
|
|
||||||
Orb.responseBodies
|
|
||||||
. Orb.addResponseSchema200 Orb.successMessageSchema
|
|
||||||
. Orb.addResponseSchema500 Orb.internalServerErrorSchema
|
|
||||||
$ Orb.noResponseBodies
|
|
||||||
, Orb.mkPermissionAction =
|
|
||||||
\_request -> NoPermissions
|
|
||||||
, Orb.handleRequest =
|
|
||||||
\_request () -> Orb.return200 (Orb.SuccessMessage "ok")
|
|
||||||
}
|
|
||||||
|
|
||||||
-- NoPermissions — all routes are public for now.
|
|
||||||
|
|
||||||
data NoPermissions = NoPermissions
|
|
||||||
|
|
||||||
instance Orb.PermissionAction NoPermissions where
|
|
||||||
type PermissionActionMonad NoPermissions = SisDispatchM
|
|
||||||
type PermissionActionError NoPermissions = NoError
|
|
||||||
type PermissionActionResult NoPermissions = ()
|
|
||||||
|
|
||||||
checkPermissionAction _ =
|
|
||||||
pure (Right ())
|
|
||||||
|
|
||||||
newtype NoError = NoError Void
|
|
||||||
|
|
||||||
instance Orb.PermissionError NoError where
|
|
||||||
type PermissionErrorConstraints NoError _tags = ()
|
|
||||||
type PermissionErrorMonad NoError = SisDispatchM
|
|
||||||
|
|
||||||
returnPermissionError (NoError v) =
|
|
||||||
absurd v
|
|
||||||
|
|||||||
+591
-91
@@ -1,127 +1,627 @@
|
|||||||
|
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||||
|
|
||||||
{- | Core domain types for Sis.
|
{- | Core domain types for Sis.
|
||||||
|
|
||||||
Sis tracks tasks (chores, responsibilities) that are shared among
|
Covers users, households, memberships, invites, chores with
|
||||||
members of a household or group. Any user can complete a task, and
|
schedules, occurrences, and activity records.
|
||||||
completion is visible to all.
|
|
||||||
-}
|
-}
|
||||||
module Sis.Types (
|
module Sis.Types (
|
||||||
-- * Task
|
-- * IDs
|
||||||
Task (..),
|
UserId (..),
|
||||||
TaskId,
|
HouseholdId (..),
|
||||||
TaskName,
|
ChoreId (..),
|
||||||
TaskStatus (..),
|
OccurrenceId (..),
|
||||||
|
ActivityId (..),
|
||||||
-- * User
|
InviteId (..),
|
||||||
|
UserPublic (..),
|
||||||
User (..),
|
User (..),
|
||||||
UserId,
|
SignupRequest (..),
|
||||||
UserName,
|
LoginRequest (..),
|
||||||
|
|
||||||
-- * Task completion
|
-- * Household
|
||||||
TaskCompletion (..),
|
Household (..),
|
||||||
|
Membership (..),
|
||||||
|
MemberRole (..),
|
||||||
|
Invite (..),
|
||||||
|
InviteStatus (..),
|
||||||
|
CreateHouseholdRequest (..),
|
||||||
|
CreateInviteRequest (..),
|
||||||
|
|
||||||
|
-- * Chore
|
||||||
|
Chore (..),
|
||||||
|
ChoreAssignee (..),
|
||||||
|
Schedule (..),
|
||||||
|
SchedulePeriod (..),
|
||||||
|
CreateChoreRequest (..),
|
||||||
|
UpdateChoreRequest (..),
|
||||||
|
|
||||||
|
-- * Occurrence
|
||||||
|
Occurrence (..),
|
||||||
|
OccurrenceStatus (..),
|
||||||
|
|
||||||
|
-- * Activity
|
||||||
|
Activity (..),
|
||||||
|
ActivityStatus (..),
|
||||||
|
RecordActivityRequest (..),
|
||||||
|
|
||||||
|
-- * Dashboard
|
||||||
|
Dashboard (..),
|
||||||
|
DashboardStats (..),
|
||||||
|
DueItem (..),
|
||||||
|
CompletedItem (..),
|
||||||
|
|
||||||
|
-- * Activity log
|
||||||
|
ActivityLogEntry (..),
|
||||||
|
ActivityLogPage (..),
|
||||||
|
|
||||||
|
-- * Auth responses
|
||||||
|
AuthResponse (..),
|
||||||
|
|
||||||
|
-- * Error
|
||||||
|
ErrorResponse (..),
|
||||||
|
|
||||||
|
-- * Seed
|
||||||
|
SeedRequest (..),
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Data.Aeson qualified as A
|
import Data.Aeson qualified as A
|
||||||
import Data.Text (Text)
|
import Data.Text (Text)
|
||||||
import Data.Time (UTCTime)
|
import Data.Time (Day, LocalTime, UTCTime)
|
||||||
|
|
||||||
-- | Unique identifier for a task.
|
----------------------------------------------------------------------
|
||||||
type TaskId = Int
|
-- IDs
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
-- | Human-readable task name.
|
newtype UserId = UserId {unUserId :: Int}
|
||||||
type TaskName = Text
|
deriving newtype (Show, Eq, Read, A.ToJSON, A.FromJSON, A.ToJSONKey, A.FromJSONKey)
|
||||||
|
|
||||||
-- | Whether a task is pending or done.
|
newtype HouseholdId = HouseholdId {unHouseholdId :: Int}
|
||||||
data TaskStatus
|
deriving newtype (Show, Eq, Read, A.ToJSON, A.FromJSON, A.ToJSONKey, A.FromJSONKey)
|
||||||
= TaskPending
|
|
||||||
| TaskDone
|
|
||||||
deriving stock (Show, Eq)
|
|
||||||
|
|
||||||
-- | A chore or responsibility that needs to be completed.
|
newtype ChoreId = ChoreId {unChoreId :: Int}
|
||||||
data Task = Task
|
deriving newtype (Show, Eq, Read, A.ToJSON, A.FromJSON)
|
||||||
{ taskId :: TaskId
|
|
||||||
, taskName :: TaskName
|
|
||||||
, taskStatus :: TaskStatus
|
|
||||||
, taskAssignedTo :: Maybe UserId
|
|
||||||
, taskLastCompleted :: Maybe UTCTime
|
|
||||||
}
|
|
||||||
deriving stock (Show, Eq)
|
|
||||||
|
|
||||||
-- | Unique identifier for a user.
|
newtype OccurrenceId = OccurrenceId {unOccurrenceId :: Int}
|
||||||
type UserId = Int
|
deriving newtype (Show, Eq, Read, A.ToJSON, A.FromJSON)
|
||||||
|
|
||||||
-- | Display name for a user.
|
newtype ActivityId = ActivityId {unActivityId :: Int}
|
||||||
type UserName = Text
|
deriving newtype (Show, Eq, Read, A.ToJSON, A.FromJSON)
|
||||||
|
|
||||||
|
newtype InviteId = InviteId {unInviteId :: Int}
|
||||||
|
deriving newtype (Show, Eq, Read, A.ToJSON, A.FromJSON)
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- User
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
-- | A user who can complete tasks.
|
|
||||||
data User = User
|
data User = User
|
||||||
{ userId :: UserId
|
{ userId :: UserId
|
||||||
, userName :: UserName
|
, userDisplayName :: Text
|
||||||
|
, userEmail :: Text
|
||||||
|
, userPasswordHash :: Text
|
||||||
}
|
}
|
||||||
deriving stock (Show, Eq)
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
-- | Records when a user completed a task.
|
-- | Public user info (never includes password hash)
|
||||||
data TaskCompletion = TaskCompletion
|
data UserPublic = UserPublic
|
||||||
{ completionTaskId :: TaskId
|
{ upId :: UserId
|
||||||
, completionUserId :: UserId
|
, upDisplayName :: Text
|
||||||
, completionTime :: UTCTime
|
, upEmail :: Text
|
||||||
}
|
}
|
||||||
deriving stock (Show, Eq)
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
-- JSON instances
|
instance A.ToJSON UserPublic where
|
||||||
|
toJSON u =
|
||||||
instance A.ToJSON TaskStatus where
|
|
||||||
toJSON TaskPending = A.String "pending"
|
|
||||||
toJSON TaskDone = A.String "done"
|
|
||||||
|
|
||||||
instance A.FromJSON TaskStatus where
|
|
||||||
parseJSON = A.withText "TaskStatus" $ \case
|
|
||||||
"pending" -> pure TaskPending
|
|
||||||
"done" -> pure TaskDone
|
|
||||||
other -> fail $ "Unknown TaskStatus: " <> show other
|
|
||||||
|
|
||||||
instance A.ToJSON Task where
|
|
||||||
toJSON Task{..} =
|
|
||||||
A.object
|
A.object
|
||||||
[ "id" A..= taskId
|
[ "id" A..= upId u
|
||||||
, "name" A..= taskName
|
, "displayName" A..= upDisplayName u
|
||||||
, "status" A..= taskStatus
|
, "email" A..= upEmail u
|
||||||
, "assignedTo" A..= taskAssignedTo
|
|
||||||
, "lastCompleted" A..= taskLastCompleted
|
|
||||||
]
|
]
|
||||||
|
|
||||||
instance A.FromJSON Task where
|
data SignupRequest = SignupRequest
|
||||||
parseJSON = A.withObject "Task" $ \o ->
|
{ srDisplayName :: Text
|
||||||
Task
|
, srEmail :: Text
|
||||||
<$> o A..: "id"
|
, srPassword :: Text
|
||||||
<*> o A..: "name"
|
, srConfirmPassword :: Text
|
||||||
<*> o A..: "status"
|
, srAgreeTerms :: Bool
|
||||||
<*> o A..: "assignedTo"
|
}
|
||||||
<*> o A..: "lastCompleted"
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
instance A.ToJSON User where
|
instance A.FromJSON SignupRequest where
|
||||||
toJSON User{..} =
|
parseJSON = A.withObject "SignupRequest" $ \o ->
|
||||||
|
SignupRequest
|
||||||
|
<$> o A..: "displayName"
|
||||||
|
<*> o A..: "email"
|
||||||
|
<*> o A..: "password"
|
||||||
|
<*> o A..: "confirmPassword"
|
||||||
|
<*> o A..: "agreeTerms"
|
||||||
|
|
||||||
|
data LoginRequest = LoginRequest
|
||||||
|
{ lrEmail :: Text
|
||||||
|
, lrPassword :: Text
|
||||||
|
, lrRememberMe :: Bool
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.FromJSON LoginRequest where
|
||||||
|
parseJSON = A.withObject "LoginRequest" $ \o ->
|
||||||
|
LoginRequest
|
||||||
|
<$> o A..: "email"
|
||||||
|
<*> o A..: "password"
|
||||||
|
<*> o A..: "rememberMe"
|
||||||
|
|
||||||
|
data AuthResponse = AuthResponse
|
||||||
|
{ arUser :: UserPublic
|
||||||
|
, arHouseholds :: [Household]
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON AuthResponse where
|
||||||
|
toJSON r =
|
||||||
A.object
|
A.object
|
||||||
[ "id" A..= userId
|
[ "user" A..= arUser r
|
||||||
, "name" A..= userName
|
, "households" A..= arHouseholds r
|
||||||
]
|
]
|
||||||
|
|
||||||
instance A.FromJSON User where
|
----------------------------------------------------------------------
|
||||||
parseJSON = A.withObject "User" $ \o ->
|
-- Household
|
||||||
User
|
----------------------------------------------------------------------
|
||||||
<$> o A..: "id"
|
|
||||||
<*> o A..: "name"
|
|
||||||
|
|
||||||
instance A.ToJSON TaskCompletion where
|
data MemberRole = OwnerRole | MemberRole
|
||||||
toJSON TaskCompletion{..} =
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON MemberRole where
|
||||||
|
toJSON OwnerRole = A.String "owner"
|
||||||
|
toJSON MemberRole = A.String "member"
|
||||||
|
|
||||||
|
instance A.FromJSON MemberRole where
|
||||||
|
parseJSON = A.withText "MemberRole" $ \case
|
||||||
|
"owner" -> pure OwnerRole
|
||||||
|
"member" -> pure MemberRole
|
||||||
|
other -> fail $ "Unknown MemberRole: " <> show other
|
||||||
|
|
||||||
|
data Household = Household
|
||||||
|
{ householdId :: HouseholdId
|
||||||
|
, householdName :: Text
|
||||||
|
, householdOwner :: UserId
|
||||||
|
, householdMemberCount :: Int
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON Household where
|
||||||
|
toJSON h =
|
||||||
A.object
|
A.object
|
||||||
[ "taskId" A..= completionTaskId
|
[ "id" A..= householdId h
|
||||||
, "userId" A..= completionUserId
|
, "name" A..= householdName h
|
||||||
, "time" A..= completionTime
|
, "owner" A..= householdOwner h
|
||||||
|
, "memberCount" A..= householdMemberCount h
|
||||||
]
|
]
|
||||||
|
|
||||||
instance A.FromJSON TaskCompletion where
|
data Membership = Membership
|
||||||
parseJSON = A.withObject "TaskCompletion" $ \o ->
|
{ membershipUserId :: UserId
|
||||||
TaskCompletion
|
, membershipDisplayName :: Text
|
||||||
<$> o A..: "taskId"
|
, membershipEmail :: Text
|
||||||
<*> o A..: "userId"
|
, membershipRole :: MemberRole
|
||||||
<*> o A..: "time"
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON Membership where
|
||||||
|
toJSON m =
|
||||||
|
A.object
|
||||||
|
[ "userId" A..= membershipUserId m
|
||||||
|
, "displayName" A..= membershipDisplayName m
|
||||||
|
, "email" A..= membershipEmail m
|
||||||
|
, "role" A..= membershipRole m
|
||||||
|
]
|
||||||
|
|
||||||
|
data InviteStatus = InvitePending | InviteAccepted | InviteRevoked
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON InviteStatus where
|
||||||
|
toJSON InvitePending = A.String "pending"
|
||||||
|
toJSON InviteAccepted = A.String "accepted"
|
||||||
|
toJSON InviteRevoked = A.String "revoked"
|
||||||
|
|
||||||
|
instance A.FromJSON InviteStatus where
|
||||||
|
parseJSON = A.withText "InviteStatus" $ \case
|
||||||
|
"pending" -> pure InvitePending
|
||||||
|
"accepted" -> pure InviteAccepted
|
||||||
|
"revoked" -> pure InviteRevoked
|
||||||
|
other -> fail $ "Unknown InviteStatus: " <> show other
|
||||||
|
|
||||||
|
data Invite = Invite
|
||||||
|
{ inviteId :: InviteId
|
||||||
|
, inviteHouseholdId :: HouseholdId
|
||||||
|
, inviteCode :: Text
|
||||||
|
, inviteEmail :: Maybe Text
|
||||||
|
, inviteStatus :: InviteStatus
|
||||||
|
, inviteCreatedAt :: UTCTime
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON Invite where
|
||||||
|
toJSON i =
|
||||||
|
A.object
|
||||||
|
[ "id" A..= inviteId i
|
||||||
|
, "code" A..= inviteCode i
|
||||||
|
, "email" A..= inviteEmail i
|
||||||
|
, "status" A..= inviteStatus i
|
||||||
|
, "createdAt" A..= inviteCreatedAt i
|
||||||
|
]
|
||||||
|
|
||||||
|
newtype CreateHouseholdRequest = CreateHouseholdRequest
|
||||||
|
{ chrName :: Text
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.FromJSON CreateHouseholdRequest where
|
||||||
|
parseJSON = A.withObject "CreateHouseholdRequest" $ \o ->
|
||||||
|
CreateHouseholdRequest <$> o A..: "name"
|
||||||
|
|
||||||
|
newtype CreateInviteRequest = CreateInviteRequest
|
||||||
|
{ cirEmail :: Maybe Text
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.FromJSON CreateInviteRequest where
|
||||||
|
parseJSON = A.withObject "CreateInviteRequest" $ \o ->
|
||||||
|
CreateInviteRequest <$> o A..: "email"
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Chore
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
data ChoreAssignee
|
||||||
|
= AssigneeUser UserId
|
||||||
|
| AssigneeAnyone
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON ChoreAssignee where
|
||||||
|
toJSON (AssigneeUser uid) = A.object ["type" A..= A.String "user", "userId" A..= uid]
|
||||||
|
toJSON AssigneeAnyone = A.object ["type" A..= A.String "anyone"]
|
||||||
|
|
||||||
|
instance A.FromJSON ChoreAssignee where
|
||||||
|
parseJSON = A.withObject "ChoreAssignee" $ \o -> do
|
||||||
|
ty <- o A..: "type"
|
||||||
|
case (ty :: Text) of
|
||||||
|
"user" -> AssigneeUser <$> o A..: "userId"
|
||||||
|
"anyone" -> pure AssigneeAnyone
|
||||||
|
other -> fail $ "Unknown ChoreAssignee type: " <> show other
|
||||||
|
|
||||||
|
data SchedulePeriod = PeriodDaily | PeriodWeekly | PeriodMonthly
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON SchedulePeriod where
|
||||||
|
toJSON PeriodDaily = A.String "daily"
|
||||||
|
toJSON PeriodWeekly = A.String "weekly"
|
||||||
|
toJSON PeriodMonthly = A.String "monthly"
|
||||||
|
|
||||||
|
instance A.FromJSON SchedulePeriod where
|
||||||
|
parseJSON = A.withText "SchedulePeriod" $ \case
|
||||||
|
"daily" -> pure PeriodDaily
|
||||||
|
"weekly" -> pure PeriodWeekly
|
||||||
|
"monthly" -> pure PeriodMonthly
|
||||||
|
other -> fail $ "Unknown SchedulePeriod: " <> show other
|
||||||
|
|
||||||
|
data Schedule
|
||||||
|
= ScheduleOneOff {soDate :: Day, soTime :: Maybe LocalTime}
|
||||||
|
| ScheduleRecurring
|
||||||
|
{ srPeriod :: SchedulePeriod
|
||||||
|
, srStartDate :: Day
|
||||||
|
, srTimeOfDay :: Maybe Text
|
||||||
|
, srDaysOfWeek :: Maybe [Int]
|
||||||
|
, srDaysOfMonth :: Maybe [Int]
|
||||||
|
}
|
||||||
|
| ScheduleSometime
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON Schedule where
|
||||||
|
toJSON (ScheduleOneOff date mtime) =
|
||||||
|
A.object
|
||||||
|
[ "type" A..= A.String "one_off"
|
||||||
|
, "date" A..= date
|
||||||
|
, "time" A..= mtime
|
||||||
|
]
|
||||||
|
toJSON (ScheduleRecurring period start tod dows doms) =
|
||||||
|
A.object
|
||||||
|
[ "type" A..= A.String "recurring"
|
||||||
|
, "period" A..= period
|
||||||
|
, "startDate" A..= start
|
||||||
|
, "timeOfDay" A..= tod
|
||||||
|
, "daysOfWeek" A..= dows
|
||||||
|
, "daysOfMonth" A..= doms
|
||||||
|
]
|
||||||
|
toJSON ScheduleSometime =
|
||||||
|
A.object ["type" A..= A.String "sometime"]
|
||||||
|
|
||||||
|
instance A.FromJSON Schedule where
|
||||||
|
parseJSON = A.withObject "Schedule" $ \o -> do
|
||||||
|
ty <- o A..: "type"
|
||||||
|
case (ty :: Text) of
|
||||||
|
"one_off" ->
|
||||||
|
ScheduleOneOff
|
||||||
|
<$> o A..: "date"
|
||||||
|
<*> o A..: "time"
|
||||||
|
"recurring" ->
|
||||||
|
ScheduleRecurring
|
||||||
|
<$> o A..: "period"
|
||||||
|
<*> o A..: "startDate"
|
||||||
|
<*> o A..: "timeOfDay"
|
||||||
|
<*> o A..: "daysOfWeek"
|
||||||
|
<*> o A..: "daysOfMonth"
|
||||||
|
"sometime" -> pure ScheduleSometime
|
||||||
|
other -> fail $ "Unknown Schedule type: " <> show other
|
||||||
|
|
||||||
|
data Chore = Chore
|
||||||
|
{ choreId :: ChoreId
|
||||||
|
, choreHouseholdId :: HouseholdId
|
||||||
|
, choreName :: Text
|
||||||
|
, choreAssignee :: ChoreAssignee
|
||||||
|
, choreSchedule :: Schedule
|
||||||
|
, choreNotifyOnDue :: Bool
|
||||||
|
, choreCreatedAt :: UTCTime
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON Chore where
|
||||||
|
toJSON c =
|
||||||
|
A.object
|
||||||
|
[ "id" A..= choreId c
|
||||||
|
, "householdId" A..= choreHouseholdId c
|
||||||
|
, "name" A..= choreName c
|
||||||
|
, "assignee" A..= choreAssignee c
|
||||||
|
, "schedule" A..= choreSchedule c
|
||||||
|
, "notifyOnDue" A..= choreNotifyOnDue c
|
||||||
|
, "createdAt" A..= choreCreatedAt c
|
||||||
|
]
|
||||||
|
|
||||||
|
data CreateChoreRequest = CreateChoreRequest
|
||||||
|
{ ccrName :: Text
|
||||||
|
, ccrAssignee :: ChoreAssignee
|
||||||
|
, ccrSchedule :: Schedule
|
||||||
|
, ccrNotifyOnDue :: Bool
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.FromJSON CreateChoreRequest where
|
||||||
|
parseJSON = A.withObject "CreateChoreRequest" $ \o ->
|
||||||
|
CreateChoreRequest
|
||||||
|
<$> o A..: "name"
|
||||||
|
<*> o A..: "assignee"
|
||||||
|
<*> o A..: "schedule"
|
||||||
|
<*> o A..: "notifyOnDue"
|
||||||
|
|
||||||
|
data UpdateChoreRequest = UpdateChoreRequest
|
||||||
|
{ ucrName :: Text
|
||||||
|
, ucrAssignee :: ChoreAssignee
|
||||||
|
, ucrSchedule :: Schedule
|
||||||
|
, ucrNotifyOnDue :: Bool
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.FromJSON UpdateChoreRequest where
|
||||||
|
parseJSON = A.withObject "UpdateChoreRequest" $ \o ->
|
||||||
|
UpdateChoreRequest
|
||||||
|
<$> o A..: "name"
|
||||||
|
<*> o A..: "assignee"
|
||||||
|
<*> o A..: "schedule"
|
||||||
|
<*> o A..: "notifyOnDue"
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Occurrence
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
data OccurrenceStatus = OccDue | OccOverdue | OccCompleted | OccSkipped
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON OccurrenceStatus where
|
||||||
|
toJSON OccDue = A.String "due"
|
||||||
|
toJSON OccOverdue = A.String "overdue"
|
||||||
|
toJSON OccCompleted = A.String "completed"
|
||||||
|
toJSON OccSkipped = A.String "skipped"
|
||||||
|
|
||||||
|
data Occurrence = Occurrence
|
||||||
|
{ occurrenceId :: OccurrenceId
|
||||||
|
, occurrenceChoreId :: ChoreId
|
||||||
|
, occurrenceDate :: Day
|
||||||
|
, occurrenceStatus :: OccurrenceStatus
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON Occurrence where
|
||||||
|
toJSON o =
|
||||||
|
A.object
|
||||||
|
[ "id" A..= occurrenceId o
|
||||||
|
, "choreId" A..= occurrenceChoreId o
|
||||||
|
, "date" A..= occurrenceDate o
|
||||||
|
, "status" A..= occurrenceStatus o
|
||||||
|
]
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Activity
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
data ActivityStatus = ActivityCompleted | ActivitySkipped
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON ActivityStatus where
|
||||||
|
toJSON ActivityCompleted = A.String "completed"
|
||||||
|
toJSON ActivitySkipped = A.String "skipped"
|
||||||
|
|
||||||
|
instance A.FromJSON ActivityStatus where
|
||||||
|
parseJSON = A.withText "ActivityStatus" $ \case
|
||||||
|
"completed" -> pure ActivityCompleted
|
||||||
|
"skipped" -> pure ActivitySkipped
|
||||||
|
other -> fail $ "Unknown ActivityStatus: " <> show other
|
||||||
|
|
||||||
|
data Activity = Activity
|
||||||
|
{ activityId :: ActivityId
|
||||||
|
, activityOccurrenceId :: OccurrenceId
|
||||||
|
, activityUserId :: UserId
|
||||||
|
, activityStatus :: ActivityStatus
|
||||||
|
, activityNote :: Maybe Text
|
||||||
|
, activityNotifyHousehold :: Bool
|
||||||
|
, activityRecordedAt :: UTCTime
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON Activity where
|
||||||
|
toJSON a =
|
||||||
|
A.object
|
||||||
|
[ "id" A..= activityId a
|
||||||
|
, "occurrenceId" A..= activityOccurrenceId a
|
||||||
|
, "userId" A..= activityUserId a
|
||||||
|
, "status" A..= activityStatus a
|
||||||
|
, "note" A..= activityNote a
|
||||||
|
, "notifyHousehold" A..= activityNotifyHousehold a
|
||||||
|
, "recordedAt" A..= activityRecordedAt a
|
||||||
|
]
|
||||||
|
|
||||||
|
data RecordActivityRequest = RecordActivityRequest
|
||||||
|
{ rarStatus :: ActivityStatus
|
||||||
|
, rarNote :: Maybe Text
|
||||||
|
, rarNotifyHousehold :: Bool
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.FromJSON RecordActivityRequest where
|
||||||
|
parseJSON = A.withObject "RecordActivityRequest" $ \o ->
|
||||||
|
RecordActivityRequest
|
||||||
|
<$> o A..: "status"
|
||||||
|
<*> o A..: "note"
|
||||||
|
<*> o A..: "notifyHousehold"
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Dashboard
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
data DashboardStats = DashboardStats
|
||||||
|
{ dsOverdue :: Int
|
||||||
|
, dsDueToday :: Int
|
||||||
|
, dsDoneThisWeek :: Int
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON DashboardStats where
|
||||||
|
toJSON s =
|
||||||
|
A.object
|
||||||
|
[ "overdue" A..= dsOverdue s
|
||||||
|
, "dueToday" A..= dsDueToday s
|
||||||
|
, "doneThisWeek" A..= dsDoneThisWeek s
|
||||||
|
]
|
||||||
|
|
||||||
|
-- | An occurrence with chore and assignee info attached for display
|
||||||
|
data DueItem = DueItem
|
||||||
|
{ diOccurrence :: Occurrence
|
||||||
|
, diChoreName :: Text
|
||||||
|
, diAssigneeName :: Maybe Text
|
||||||
|
, diIsOverdue :: Bool
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON DueItem where
|
||||||
|
toJSON d =
|
||||||
|
A.object
|
||||||
|
[ "occurrence" A..= diOccurrence d
|
||||||
|
, "choreName" A..= diChoreName d
|
||||||
|
, "assigneeName" A..= diAssigneeName d
|
||||||
|
, "isOverdue" A..= diIsOverdue d
|
||||||
|
]
|
||||||
|
|
||||||
|
-- | A completed activity with user info for display
|
||||||
|
data CompletedItem = CompletedItem
|
||||||
|
{ ciActivity :: Activity
|
||||||
|
, ciUserName :: Text
|
||||||
|
, ciChoreName :: Text
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON CompletedItem where
|
||||||
|
toJSON c =
|
||||||
|
A.object
|
||||||
|
[ "activity" A..= ciActivity c
|
||||||
|
, "userName" A..= ciUserName c
|
||||||
|
, "choreName" A..= ciChoreName c
|
||||||
|
]
|
||||||
|
|
||||||
|
data Dashboard = Dashboard
|
||||||
|
{ dashStats :: DashboardStats
|
||||||
|
, dashDueItems :: [DueItem]
|
||||||
|
, dashCompletedItems :: [CompletedItem]
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON Dashboard where
|
||||||
|
toJSON d =
|
||||||
|
A.object
|
||||||
|
[ "stats" A..= dashStats d
|
||||||
|
, "dueItems" A..= dashDueItems d
|
||||||
|
, "completedItems" A..= dashCompletedItems d
|
||||||
|
]
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Activity Log
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
data ActivityLogEntry = ActivityLogEntry
|
||||||
|
{ aleActivity :: Activity
|
||||||
|
, aleUserName :: Text
|
||||||
|
, aleUserEmail :: Text
|
||||||
|
, aleChoreName :: Text
|
||||||
|
, aleOccurrenceDate :: Day
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON ActivityLogEntry where
|
||||||
|
toJSON e =
|
||||||
|
A.object
|
||||||
|
[ "activity" A..= aleActivity e
|
||||||
|
, "userName" A..= aleUserName e
|
||||||
|
, "userEmail" A..= aleUserEmail e
|
||||||
|
, "choreName" A..= aleChoreName e
|
||||||
|
, "occurrenceDate" A..= aleOccurrenceDate e
|
||||||
|
]
|
||||||
|
|
||||||
|
data ActivityLogPage = ActivityLogPage
|
||||||
|
{ alpEntries :: [ActivityLogEntry]
|
||||||
|
, alpPage :: Int
|
||||||
|
, alpPerPage :: Int
|
||||||
|
, alpTotal :: Int
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON ActivityLogPage where
|
||||||
|
toJSON p =
|
||||||
|
A.object
|
||||||
|
[ "entries" A..= alpEntries p
|
||||||
|
, "page" A..= alpPage p
|
||||||
|
, "perPage" A..= alpPerPage p
|
||||||
|
, "total" A..= alpTotal p
|
||||||
|
]
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Error
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
data ErrorResponse = ErrorResponse
|
||||||
|
{ errorMessage :: Text
|
||||||
|
, errorField :: Maybe Text
|
||||||
|
}
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.ToJSON ErrorResponse where
|
||||||
|
toJSON e =
|
||||||
|
A.object
|
||||||
|
[ "error" A..= errorMessage e
|
||||||
|
, "field" A..= errorField e
|
||||||
|
]
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
-- Seed
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
data SeedRequest = SeedRequest
|
||||||
|
deriving stock (Show, Eq)
|
||||||
|
|
||||||
|
instance A.FromJSON SeedRequest where
|
||||||
|
parseJSON _ = pure SeedRequest
|
||||||
|
|||||||
Reference in New Issue
Block a user