diff --git a/.env.example b/.env.example index d0eaa7b..a8571c6 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,19 @@ # HTTP port the webhook server listens on PORT=8080 -# Path to the SQLite database file +# Path to the SQLite database file (the zero-config default) DB_PATH=./data/standup.db +# Bring your own database: set a PostgreSQL connection string and the +# embedded SQLite is skipped entirely. Works with managed Postgres +# (RDS, Cloud SQL, Neon, Supabase, …) or the bundled compose service +# (docker compose --profile postgres up -d). +# DATABASE_URL=postgres://asyncup:password@postgres:5432/asyncup +DATABASE_URL= + +# Password for the optional bundled Postgres compose service +# POSTGRES_PASSWORD= + # Chat adapter: "google" for production, "fake" for local demo (logs to console) ADAPTER=google diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43eec70..15e81f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,31 @@ jobs: name: coverage path: coverage/lcov.info + test-postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_PASSWORD: ci + POSTGRES_DB: asyncup_ci + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm test + env: + TEST_DATABASE_URL: postgres://postgres:ci@localhost:5432/asyncup_ci + docker: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index 2e4b02d..4eb4c8e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ Each answer is posted as one card per person under a **per-date thread** in your - **Insights** — `trends` (participation + mood over 4 weeks), weekly digest (`digest on`), CSV export endpoint. - **AI summaries, bring your own key** — opt-in daily TL;DR and week-in-review via your Anthropic/OpenAI key; nothing leaves your infra otherwise. - **Team admins & multiple standups per space** — config restricted to admins; address standups by `#id`. -- **Lightweight forever** — one container, SQLite inside (auto-migrating schema), scale-to-zero friendly (`/tick` + free-tier cron ≈ $0/month). +- **Lightweight forever** — one container, SQLite inside (auto-migrating schema), scale-to-zero friendly (`/tick` + free-tier cron ≈ $0/month). Runs happily on 1 vCPU / 512 MB. +- **Bring your own database** — set `DATABASE_URL` and AsyncUp uses your PostgreSQL (managed or `docker compose --profile postgres`) instead of embedded SQLite; both engines tested in CI. - **Restart-safe** — all scheduling state lives in SQLite; ticks are idempotent. - **Platform-agnostic core** — Google Chat is an adapter; Slack and Teams are planned. diff --git a/docker-compose.yml b/docker-compose.yml index 4eb8ad5..1d5f362 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,11 +11,30 @@ services: required: false environment: DB_PATH: /data/standup.db + # Bring your own database: set DATABASE_URL in .env (any PostgreSQL — + # managed/RDS/Cloud SQL, or the bundled one below) and SQLite is skipped. + DATABASE_URL: ${DATABASE_URL:-} volumes: - standup-data:/data # Mount your GCP service account key and set # GOOGLE_APPLICATION_CREDENTIALS=/app/service-account.json in .env: # - ./service-account.json:/app/service-account.json:ro + # Optional same-machine PostgreSQL: + # docker compose --profile postgres up -d + # with DATABASE_URL=postgres://asyncup:${POSTGRES_PASSWORD}@postgres:5432/asyncup in .env + postgres: + image: postgres:18-alpine + profiles: [postgres] + restart: unless-stopped + environment: + POSTGRES_USER: asyncup + POSTGRES_DB: asyncup + # Required when using the profile — the postgres image refuses to start without it. + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-} + volumes: + - postgres-data:/var/lib/postgresql/data + volumes: standup-data: + postgres-data: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 3e77036..6c4ec61 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -5,7 +5,8 @@ Everything is configured via environment variables (see `.env.example`). | Variable | Default | Purpose | | --- | --- | --- | | `PORT` | `8080` | Webhook port | -| `DB_PATH` | `./data/standup.db` | SQLite database file | +| `DB_PATH` | `./data/standup.db` | SQLite database file (default storage) | +| `DATABASE_URL` | *(empty)* | Bring-your-own PostgreSQL connection string — when set, SQLite is skipped (see [Deployment](./deployment#database-embedded-or-bring-your-own)) | | `ADAPTER` | `google` | `google` for production, `fake` for a console demo | | `GOOGLE_CHAT_AUDIENCE` | *(empty)* | Your GCP project **number**. Verifies incoming requests are signed by Google Chat. Empty skips verification — local development only | | `GOOGLE_APPLICATION_CREDENTIALS` | — | Path to the service account key JSON | @@ -31,8 +32,10 @@ Everything is configured via environment variables (see `.env.example`). ## Data -All state lives in a single SQLite file (`DB_PATH`): standups, participants, -admins, runs, submissions, blockers, and the DM-space cache. Back it up like -any file; the process can restart at any time without losing or double-sending -prompts. Schema migrations run automatically on startup (tracked via -`PRAGMA user_version`), so upgrading AsyncUp is just deploying the new image. +All state — standups, participants, admins, runs, submissions, blockers, and +the DM-space cache — lives either in a single SQLite file (`DB_PATH`, the +default) or in your own PostgreSQL (`DATABASE_URL`). Back up the file or use +your database's backup story; the process can restart at any time without +losing or double-sending prompts (graceful shutdown on SIGTERM included). +Schema migrations run automatically on startup in both modes, so upgrading +AsyncUp is just deploying the new image. diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 8b1e6c6..4fe1d0b 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -1,9 +1,37 @@ # Deployment -AsyncUp is one small container with SQLite inside — no external database, no -queue, no other moving parts. Google Chat needs to reach it on a public +AsyncUp is one small container — by default with SQLite inside, so there are +no external moving parts at all. Google Chat needs to reach it on a public HTTPS URL. +## Database: embedded or bring your own + +| Mode | Configuration | When to choose it | +| --- | --- | --- | +| **Embedded SQLite** (default) | `DB_PATH` on a persistent volume | Simplest possible ops: one container, one file to back up. Plenty for any team size AsyncUp serves | +| **Bring your own PostgreSQL** | `DATABASE_URL=postgres://…` | You already run managed Postgres (RDS, Cloud SQL, Neon, Supabase, …) and want its backups/HA, or your platform has no persistent volumes (e.g. some scale-to-zero setups) | +| **Postgres on the same machine** | `docker compose --profile postgres up -d` + `DATABASE_URL=postgres://asyncup:…@postgres:5432/asyncup` | Postgres semantics without leaving the box | + +Setting `DATABASE_URL` skips SQLite entirely; the schema is created and +migrated automatically on startup in both modes. The full test suite runs +against both engines in CI on every change. + +## System requirements + +AsyncUp idles at a once-a-minute scheduler tick; load is a few webhook calls +per person per day. CPU architecture: amd64 or arm64. + +| Setup | Minimum | Recommended | +| --- | --- | --- | +| App + SQLite | 1 vCPU (shared is fine), 256 MB RAM, 1 GB disk | 1 vCPU, **512 MB RAM**, 5 GB disk | +| App + bundled Postgres | 1 vCPU, 768 MB RAM, 3 GB disk | **2 vCPU, 2 GB RAM**, 10 GB disk | +| App with external/managed DB | 1 vCPU, 256 MB RAM | 1 vCPU, 512 MB RAM | + +Realistic sizing: the Node process uses ~100–150 MB RSS; the image is ~300 MB; +a year of standups for a 50-person team is well under 100 MB of data. The +smallest VPS tier (or Cloud Run's 512 MB default) is comfortably enough — +teams into the hundreds of users don't change this picture. + ## Prebuilt image Multi-arch images (`linux/amd64`, `linux/arm64`) are published to GHCR on diff --git a/package-lock.json b/package-lock.json index 635e62c..51831a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,14 +12,17 @@ "@googleapis/chat": "^45.0.0", "better-sqlite3": "^12.10.0", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "google-auth-library": "^10.7.0", - "luxon": "^3.7.2" + "luxon": "^3.7.2", + "pg": "^8.21.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/luxon": "^3.7.1", "@types/node": "^25.9.3", + "@types/pg": "^8.20.0", "@vitest/coverage-v8": "^4.1.8", "tsx": "^4.22.4", "typescript": "^6.0.3", @@ -1914,6 +1917,18 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -3139,6 +3154,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3684,6 +3717,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4600,6 +4642,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4649,6 +4780,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/preact": { "version": "10.29.2", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", @@ -5235,6 +5405,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -6623,6 +6802,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 207fef6..faa1b10 100644 --- a/package.json +++ b/package.json @@ -23,14 +23,17 @@ "@googleapis/chat": "^45.0.0", "better-sqlite3": "^12.10.0", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "google-auth-library": "^10.7.0", - "luxon": "^3.7.2" + "luxon": "^3.7.2", + "pg": "^8.21.0" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", "@types/luxon": "^3.7.1", "@types/node": "^25.9.3", + "@types/pg": "^8.20.0", "@vitest/coverage-v8": "^4.1.8", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/src/adapters/gchat/adapter.ts b/src/adapters/gchat/adapter.ts index 418c35b..0c33b26 100644 --- a/src/adapters/gchat/adapter.ts +++ b/src/adapters/gchat/adapter.ts @@ -84,12 +84,12 @@ export class GoogleChatAdapter implements ChatAdapter { * space name to avoid a lookup on every send. */ private async ensureDmSpace(userName: string): Promise { - const cached = this.repo.getDmSpace(userName); + const cached = await this.repo.getDmSpace(userName); if (cached) return cached; try { const res = await this.client.spaces.findDirectMessage({ name: userName }); const spaceName = res.data.name!; - this.repo.setDmSpace(userName, spaceName); + await this.repo.setDmSpace(userName, spaceName); return spaceName; } catch (err: any) { if (err?.response?.status === 404 || err?.code === 404) { diff --git a/src/adapters/gchat/events.ts b/src/adapters/gchat/events.ts index 6773bf5..4a6f4fa 100644 --- a/src/adapters/gchat/events.ts +++ b/src/adapters/gchat/events.ts @@ -26,7 +26,7 @@ export class EventRouter { async handle(event: any): Promise { // Learn user emails from any interaction — needed for calendar OOO lookups. if (event?.user?.name && event.user.email) { - this.repo.setUserEmail(event.user.name, event.user.email); + await this.repo.setUserEmail(event.user.name, event.user.email); } switch (event?.type) { case 'ADDED_TO_SPACE': @@ -51,13 +51,13 @@ export class EventRouter { }; } - private onMessage(event: any): object { + private async onMessage(event: any): Promise { const user = eventUser(event); if (isDm(event)) { return this.onDirectMessage(event, user); } const text: string = event.message?.argumentText ?? event.message?.text ?? ''; - const reply = this.commands.handle({ + const reply = await this.commands.handle({ tenantId: this.tenantId, spaceName: event.space?.name ?? '', text, @@ -67,10 +67,10 @@ export class EventRouter { return { text: reply }; } - private onDirectMessage(event: any, user: Mention): object { + private async onDirectMessage(event: any, user: Mention): Promise { const text = (event.message?.argumentText ?? event.message?.text ?? '').trim().toLowerCase(); if (text === 'vacation' || text === 'ooo') { - const affected = this.repo.setVacationForUser(user.userName, true); + const affected = await this.repo.setVacationForUser(user.userName, true); return { text: affected ? `🏖️ Vacation mode ON across ${affected} standup${affected === 1 ? '' : 's'} — you won't be prompted or counted as missing. DM me \`back\` when you return.` @@ -78,7 +78,7 @@ export class EventRouter { }; } if (text === 'back') { - const affected = this.repo.setVacationForUser(user.userName, false); + const affected = await this.repo.setVacationForUser(user.userName, false); return { text: affected ? `👋 Welcome back! Vacation mode is off — prompts resume with the next run.` @@ -100,10 +100,10 @@ export class EventRouter { if (fn === OPEN_DIALOG_FN) { if (!Number.isInteger(runId)) return dialogError('This standup prompt is no longer valid.'); - const run = this.repo.getRunById(runId); + const run = await this.repo.getRunById(runId); if (!run) return dialogError('This standup prompt is no longer valid.'); - const standup = this.repo.getStandupById(run.standupId)!; - const prefill = this.service.getPrefill(standup, run, user.userName); + const standup = (await this.repo.getStandupById(run.standupId))!; + const prefill = await this.service.getPrefill(standup, run, user.userName); return standupDialog(runId, standupQuestions(standup), standup.moodEnabled, prefill); } @@ -112,7 +112,7 @@ export class EventRouter { } if (fn === SKIP_TODAY_FN) { - const result = this.service.skipToday(runId, user.userName); + const result = await this.service.skipToday(runId, user.userName); const messages = { skipped: "🏖️ Skipped today's standup — you won't be counted as missing. Have a good one!", already_submitted: '✅ You already submitted today, so there is nothing to skip.', @@ -126,9 +126,9 @@ export class EventRouter { private async onDialogSubmit(event: any, runId: number, user: Mention): Promise { if (!Number.isInteger(runId)) return dialogError('This standup form is no longer valid.'); - const run = this.repo.getRunById(runId); + const run = await this.repo.getRunById(runId); if (!run) return dialogError('This standup form is no longer valid.'); - const standup = this.repo.getStandupById(run.standupId)!; + const standup = (await this.repo.getStandupById(run.standupId))!; const questions = standupQuestions(standup); const answers: Answer[] = []; diff --git a/src/config.ts b/src/config.ts index 24fe4e5..78d84ba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,8 @@ import type { LlmConfig } from './ai/llm.js'; export interface Config { port: number; dbPath: string; + /** PostgreSQL connection string; empty = embedded SQLite at DB_PATH. */ + databaseUrl: string; adapter: 'google' | 'fake'; /** GCP project number used to verify incoming Chat requests. Empty = skip (dev only). */ chatAudience: string; @@ -28,6 +30,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { return { port: Number(env.PORT ?? 8080), dbPath: env.DB_PATH ?? './data/standup.db', + databaseUrl: env.DATABASE_URL ?? '', adapter, chatAudience: env.GOOGLE_CHAT_AUDIENCE ?? '', defaultTimezone: env.DEFAULT_TIMEZONE ?? 'UTC', diff --git a/src/core/commands.ts b/src/core/commands.ts index 34f4bf5..04d1a74 100644 --- a/src/core/commands.ts +++ b/src/core/commands.ts @@ -53,7 +53,7 @@ export class CommandHandler { private now: () => DateTime = () => DateTime.utc(), ) {} - handle(ctx: CommandContext): string { + async handle(ctx: CommandContext): Promise { const tokens = ctx.text.trim().split(/\s+/).filter(Boolean); let standupRef: number | null = null; @@ -70,13 +70,13 @@ export class CommandHandler { if (command === '' || command === 'help') return HELP; if (command === 'setup') return this.setup(ctx, arg); - const standups = this.repo.listStandupsBySpace(ctx.tenantId, ctx.spaceName); + const standups = await this.repo.listStandupsBySpace(ctx.tenantId, ctx.spaceName); if (standups.length === 0) { return 'No standup is configured for this space yet. Run `setup` first.'; } if (command === 'status' && standupRef === null) { - return standups.map((s) => this.status(s, standups.length > 1)).join('\n\n'); + return (await Promise.all(standups.map((s) => this.status(s, standups.length > 1)))).join('\n\n'); } let standup: Standup; @@ -91,7 +91,7 @@ export class CommandHandler { } if (!OPEN_COMMANDS.has(command)) { - const denied = this.requireAdmin(standup, ctx.sender); + const denied = await this.requireAdmin(standup, ctx.sender); if (denied) return denied; } @@ -135,7 +135,7 @@ export class CommandHandler { case 'status': return this.status(standup, false); case 'trends': - return trendsText(this.repo, standup, this.now()); + return await trendsText(this.repo, standup, this.now()); case 'blockers': return this.blockers(standup); case 'export': @@ -145,25 +145,25 @@ export class CommandHandler { } } - private requireAdmin(standup: Standup, sender: Mention): string | null { - const admins = this.repo.listAdmins(standup.id); + private async requireAdmin(standup: Standup, sender: Mention): Promise { + const admins = await this.repo.listAdmins(standup.id); if (admins.length === 0 || admins.some((a) => a.userName === sender.userName)) return null; return `🔒 Only admins of *${standup.name}* can change its configuration (${admins .map((a) => a.displayName) .join(', ')}).`; } - private setup(ctx: CommandContext, name: string): string { - const standup = this.repo.createStandup({ + private async setup(ctx: CommandContext, name: string): Promise { + const standup = await this.repo.createStandup({ tenantId: ctx.tenantId, spaceName: ctx.spaceName, name: name || 'Daily Standup', timezone: this.defaultTimezone, }); if (ctx.sender.userName) { - this.repo.addAdmin(standup.id, ctx.sender.userName, ctx.sender.displayName); + await this.repo.addAdmin(standup.id, ctx.sender.userName, ctx.sender.displayName); } - const siblings = this.repo.listStandupsBySpace(ctx.tenantId, ctx.spaceName); + const siblings = await this.repo.listStandupsBySpace(ctx.tenantId, ctx.spaceName); return ( `✅ Standup *${standup.name}* created (#${standup.id})${siblings.length > 1 ? ` — this space now has ${siblings.length} standups, prefix commands with \`#${standup.id}\`` : ''}. You are its admin.\n` + `Defaults: prompt ${standup.promptTime}, deadline ${standup.deadlineTime}, reminder ${standup.reminderMinutesBefore}m before, ${standup.timezone}, ${standup.days}.\n` + @@ -171,10 +171,10 @@ export class CommandHandler { ); } - private addParticipants(standup: Standup, mentions: Mention[]): string { + private async addParticipants(standup: Standup, mentions: Mention[]): Promise { if (mentions.length === 0) return 'Mention the people to add, e.g. `add @Asha @Rohit`.'; for (const m of mentions) { - this.repo.upsertParticipant({ + await this.repo.upsertParticipant({ standupId: standup.id, userName: m.userName, displayName: m.displayName, @@ -183,12 +183,12 @@ export class CommandHandler { return `✅ Added ${mentions.map((m) => m.displayName).join(', ')} (mandatory). Use \`optional @user\` to exclude someone from the report count.`; } - private removeParticipants(standup: Standup, mentions: Mention[]): string { + private async removeParticipants(standup: Standup, mentions: Mention[]): Promise { if (mentions.length === 0) return 'Mention the people to remove, e.g. `remove @Asha`.'; const removed: string[] = []; const unknown: string[] = []; for (const m of mentions) { - (this.repo.removeParticipant(standup.id, m.userName) ? removed : unknown).push(m.displayName); + ((await this.repo.removeParticipant(standup.id, m.userName)) ? removed : unknown).push(m.displayName); } const parts: string[] = []; if (removed.length) parts.push(`✅ Removed ${removed.join(', ')}.`); @@ -196,14 +196,14 @@ export class CommandHandler { return parts.join(' '); } - private setMandatory(standup: Standup, mentions: Mention[], mandatory: boolean): string { + private async setMandatory(standup: Standup, mentions: Mention[], mandatory: boolean): Promise { if (mentions.length === 0) { return `Mention the people to mark as ${mandatory ? 'mandatory' : 'optional'}.`; } const changed: string[] = []; const unknown: string[] = []; for (const m of mentions) { - (this.repo.setParticipantMandatory(standup.id, m.userName, mandatory) ? changed : unknown).push( + ((await this.repo.setParticipantMandatory(standup.id, m.userName, mandatory)) ? changed : unknown).push( m.displayName, ); } @@ -213,14 +213,14 @@ export class CommandHandler { return parts.join(' '); } - private setVacation(standup: Standup, mentions: Mention[], onVacation: boolean): string { + private async setVacation(standup: Standup, mentions: Mention[], onVacation: boolean): Promise { if (mentions.length === 0) { return `Mention the people, e.g. \`${onVacation ? 'vacation' : 'back'} @Asha\`.`; } const changed: string[] = []; const unknown: string[] = []; for (const m of mentions) { - (this.repo.setParticipantVacation(standup.id, m.userName, onVacation) ? changed : unknown).push( + ((await this.repo.setParticipantVacation(standup.id, m.userName, onVacation)) ? changed : unknown).push( m.displayName, ); } @@ -236,56 +236,56 @@ export class CommandHandler { return parts.join(' '); } - private addAdmins(standup: Standup, mentions: Mention[]): string { + private async addAdmins(standup: Standup, mentions: Mention[]): Promise { if (mentions.length === 0) return 'Mention the people to make admins, e.g. `admin @Asha`.'; - for (const m of mentions) this.repo.addAdmin(standup.id, m.userName, m.displayName); - return `✅ Admins now: ${this.repo.listAdmins(standup.id).map((a) => a.displayName).join(', ')}.`; + for (const m of mentions) await this.repo.addAdmin(standup.id, m.userName, m.displayName); + return `✅ Admins now: ${(await this.repo.listAdmins(standup.id)).map((a) => a.displayName).join(', ')}.`; } - private removeAdmins(standup: Standup, mentions: Mention[]): string { + private async removeAdmins(standup: Standup, mentions: Mention[]): Promise { if (mentions.length === 0) return 'Mention the admins to remove, e.g. `unadmin @Asha`.'; - const admins = this.repo.listAdmins(standup.id); + const admins = await this.repo.listAdmins(standup.id); const remaining = admins.filter((a) => !mentions.some((m) => m.userName === a.userName)); if (admins.length > 0 && remaining.length === 0) { return '⚠️ A standup must keep at least one admin — add another admin first.'; } - for (const m of mentions) this.repo.removeAdmin(standup.id, m.userName); - const now = this.repo.listAdmins(standup.id); + for (const m of mentions) await this.repo.removeAdmin(standup.id, m.userName); + const now = await this.repo.listAdmins(standup.id); return `✅ Admins now: ${now.length ? now.map((a) => a.displayName).join(', ') : 'none (configuration is open to everyone)'}.`; } - private setTime(standup: Standup, value: string, field: 'promptTime' | 'deadlineTime'): string { + private async setTime(standup: Standup, value: string, field: 'promptTime' | 'deadlineTime'): Promise { if (!TIME_RE.test(value)) return 'Please give a 24h time like `09:30`.'; const next = { promptTime: standup.promptTime, deadlineTime: standup.deadlineTime, [field]: value }; if (next.promptTime >= next.deadlineTime) { return `⚠️ Prompt time (${next.promptTime}) must be before the deadline (${next.deadlineTime}).`; } - this.repo.updateStandup(standup.id, { [field]: value }); + await this.repo.updateStandup(standup.id, { [field]: value }); return field === 'promptTime' ? `✅ Prompts will go out at ${value} (each participant's local time).` : `✅ Deadline set to ${value} ${standup.timezone}. The report posts then.`; } - private setReminder(standup: Standup, value: string): string { + private async setReminder(standup: Standup, value: string): Promise { const minutes = Number(value); if (!Number.isInteger(minutes) || minutes < 0 || minutes > 24 * 60) { return 'Please give the number of minutes before the deadline, e.g. `remind 60`. Use `remind 0` to disable.'; } - this.repo.updateStandup(standup.id, { reminderMinutesBefore: minutes }); + await this.repo.updateStandup(standup.id, { reminderMinutesBefore: minutes }); return minutes === 0 ? '✅ Reminder disabled.' : `✅ Reminder will go out ${minutes} minutes before the deadline.`; } - private setTimezone(standup: Standup, value: string): string { + private async setTimezone(standup: Standup, value: string): Promise { if (!value || !IANAZone.isValidZone(value)) { return 'Please give a valid IANA timezone, e.g. `timezone Asia/Kolkata`.'; } - this.repo.updateStandup(standup.id, { timezone: value }); + await this.repo.updateStandup(standup.id, { timezone: value }); return `✅ Timezone set to ${value}.`; } - private setDays(standup: Standup, value: string): string { + private async setDays(standup: Standup, value: string): Promise { const days = value .toLowerCase() .split(/[,\s]+/) @@ -295,14 +295,14 @@ export class CommandHandler { return 'Please list days like `days mon,tue,wed,thu,fri`.'; } const ordered = WEEKDAYS.filter((d) => days.includes(d)); - this.repo.updateStandup(standup.id, { days: ordered.join(',') }); + await this.repo.updateStandup(standup.id, { days: ordered.join(',') }); return `✅ Standup runs on: ${ordered.join(', ')}.`; } - private questions(standup: Standup, rest: string[]): string { + private async questions(standup: Standup, rest: string[]): Promise { const sub = (rest[0] ?? '').toLowerCase(); if (sub === 'reset') { - this.repo.updateStandup(standup.id, { questions: null }); + await this.repo.updateStandup(standup.id, { questions: null }); return `✅ Questions reset to the defaults:\n${DEFAULT_QUESTIONS.map((q, i) => `${i + 1}. ${q}`).join('\n')}`; } if (sub === 'set') { @@ -317,7 +317,7 @@ export class CommandHandler { } const tooLong = parts.find((q) => q.length > 200); if (tooLong) return `⚠️ Question too long (max 200 chars): "${tooLong.slice(0, 50)}…"`; - this.repo.updateStandup(standup.id, { questions: parts }); + await this.repo.updateStandup(standup.id, { questions: parts }); return `✅ Questions updated:\n${parts.map((q, i) => `${i + 1}. ${q}`).join('\n')}\nApplies from the next run.`; } const current = standupQuestions(standup); @@ -327,27 +327,27 @@ export class CommandHandler { ); } - private mood(standup: Standup, value: string): string { + private async mood(standup: Standup, value: string): Promise { switch (value.toLowerCase()) { case 'on': - this.repo.updateStandup(standup.id, { moodEnabled: true, moodAnonymous: false }); + await this.repo.updateStandup(standup.id, { moodEnabled: true, moodAnonymous: false }); return '✅ Mood question on — moods show on each card.'; case 'anon': case 'anonymous': - this.repo.updateStandup(standup.id, { moodEnabled: true, moodAnonymous: true }); + await this.repo.updateStandup(standup.id, { moodEnabled: true, moodAnonymous: true }); return '✅ Mood question on, *anonymous* — cards hide who felt what; the wrap-up shows the team average instead.'; case 'off': - this.repo.updateStandup(standup.id, { moodEnabled: false }); + await this.repo.updateStandup(standup.id, { moodEnabled: false }); return '✅ Mood question off.'; default: return 'Use `mood on`, `mood anon`, or `mood off`.'; } } - private escalate(standup: Standup, mentions: Mention[], rest: string[]): string { + private async escalate(standup: Standup, mentions: Mention[], rest: string[]): Promise { const sub = (rest[0] ?? '').toLowerCase(); if (sub === 'off') { - this.repo.updateStandup(standup.id, { escalateUserName: null, escalateDisplayName: null }); + await this.repo.updateStandup(standup.id, { escalateUserName: null, escalateDisplayName: null }); return '✅ Blocker escalation off.'; } if (sub === 'days') { @@ -355,7 +355,7 @@ export class CommandHandler { if (!Number.isInteger(days) || days < 1 || days > 30) { return 'Give the number of days a blocker may stay open, e.g. `escalate days 3`.'; } - this.repo.updateStandup(standup.id, { escalateAfterDays: days }); + await this.repo.updateStandup(standup.id, { escalateAfterDays: days }); return `✅ Blockers escalate after ${days} day${days === 1 ? '' : 's'} open.`; } const contact = mentions[0]; @@ -364,30 +364,30 @@ export class CommandHandler { ? `Escalation: DM ${standup.escalateDisplayName} when blockers are open ${standup.escalateAfterDays}+ days. \`escalate @user\`, \`escalate days N\`, or \`escalate off\` to change.` : 'Mention who should be pinged, e.g. `escalate @Asha` — they get a DM when blockers stay open too long.'; } - this.repo.updateStandup(standup.id, { + await this.repo.updateStandup(standup.id, { escalateUserName: contact.userName, escalateDisplayName: contact.displayName, }); return `✅ ${contact.displayName} will be DMed when blockers stay open ${standup.escalateAfterDays}+ days.`; } - private toggle( + private async toggle( standup: Standup, field: 'moodEnabled' | 'digestEnabled' | 'aiEnabled', value: string, label: string, - ): string { + ): Promise { const v = value.toLowerCase(); if (v !== 'on' && v !== 'off') return `Use \`on\` or \`off\`, e.g. \`${label.split(' ')[0]?.toLowerCase()} on\`.`; - this.repo.updateStandup(standup.id, { [field]: v === 'on' }); + await this.repo.updateStandup(standup.id, { [field]: v === 'on' }); if (field === 'aiEnabled' && v === 'on') { return `✅ ${label} on. Requires LLM_PROVIDER + LLM_API_KEY in the server environment — summaries are skipped silently otherwise.`; } return `✅ ${label} ${v}.`; } - private blockers(standup: Standup): string { - const open = this.repo.listOpenBlockers(standup.id); + private async blockers(standup: Standup): Promise { + const open = await this.repo.listOpenBlockers(standup.id); if (open.length === 0) return `✅ No open blockers for *${standup.name}*.`; const today = this.now().setZone(standup.timezone); const lines = open.map((b) => { @@ -407,9 +407,9 @@ export class CommandHandler { ); } - private status(standup: Standup, withId: boolean): string { - const participants = this.repo.listParticipants(standup.id); - const admins = this.repo.listAdmins(standup.id); + private async status(standup: Standup, withId: boolean): Promise { + const participants = await this.repo.listParticipants(standup.id); + const admins = await this.repo.listAdmins(standup.id); const toggles = [ standup.moodEnabled ? (standup.moodAnonymous ? 'mood ✓ (anon)' : 'mood ✓') : 'mood ✗', standup.digestEnabled ? 'digest ✓' : 'digest ✗', @@ -435,10 +435,10 @@ export class CommandHandler { ]; const today = this.now().setZone(standup.timezone).toISODate()!; - const run = this.repo.getRun(standup.id, today); + const run = await this.repo.getRun(standup.id, today); if (run) { - const submitted = new Set(this.repo.listSubmissions(run.id).map((s) => s.userName)); - const roster = this.repo.listRunParticipants(run.id); + const submitted = new Set((await this.repo.listSubmissions(run.id)).map((s) => s.userName)); + const roster = await this.repo.listRunParticipants(run.id); const done = roster.filter((p) => submitted.has(p.userName)).map((p) => p.displayName); const away = roster .filter((p) => !submitted.has(p.userName) && (p.skippedAt || p.onVacation)) diff --git a/src/core/export.ts b/src/core/export.ts index da3f4d9..2b1b7e1 100644 --- a/src/core/export.ts +++ b/src/core/export.ts @@ -6,9 +6,9 @@ function csvField(value: string): string { } /** Long-format CSV: one row per answered question per submission. */ -export function buildCsv(repo: Repo, standup: Standup, fromDate: string, toDate: string): string { +export async function buildCsv(repo: Repo, standup: Standup, fromDate: string, toDate: string): Promise { const rows = [['date', 'standup', 'person', 'late', 'edited', 'mood', 'question', 'answer']]; - for (const { submission, runDate } of repo.listSubmissionsBetween(standup.id, fromDate, toDate)) { + for (const { submission, runDate } of await repo.listSubmissionsBetween(standup.id, fromDate, toDate)) { for (const answer of submission.answers) { rows.push([ runDate, diff --git a/src/core/insights.ts b/src/core/insights.ts index 4b08240..4f2ca8f 100644 --- a/src/core/insights.ts +++ b/src/core/insights.ts @@ -2,7 +2,7 @@ import { DateTime } from 'luxon'; import type { Repo } from '../db/repo.js'; import { MOOD_SCORE, standupDays, WEEKDAYS, type Standup, type WeeklyDigest } from './types.js'; -interface RangeStats { +export interface RangeStats { runCount: number; expected: number; submitted: number; @@ -10,13 +10,18 @@ interface RangeStats { moodCount: number; } -export function rangeStats(repo: Repo, standupId: number, fromDate: string, toDate: string): RangeStats { +export async function rangeStats( + repo: Repo, + standupId: number, + fromDate: string, + toDate: string, +): Promise { const stats: RangeStats = { runCount: 0, expected: 0, submitted: 0, moodSum: 0, moodCount: 0 }; - for (const run of repo.listRunsBetween(standupId, fromDate, toDate)) { + for (const run of await repo.listRunsBetween(standupId, fromDate, toDate)) { stats.runCount++; - const submissions = repo.listSubmissions(run.id); + const submissions = await repo.listSubmissions(run.id); const submittedBy = new Set(submissions.map((s) => s.userName)); - for (const p of repo.listRunParticipants(run.id)) { + for (const p of await repo.listRunParticipants(run.id)) { if (!p.mandatory) continue; if (submittedBy.has(p.userName)) { stats.expected++; @@ -57,15 +62,15 @@ export function lastConfiguredWeekday(standup: Standup): number { return Math.max(...configured); } -export function buildWeeklyDigest(repo: Repo, standup: Standup, runDate: string): WeeklyDigest { +export async function buildWeeklyDigest(repo: Repo, standup: Standup, runDate: string): Promise { const date = DateTime.fromISO(runDate); const weekStart = date.startOf('week').toISODate()!; const weekEnd = date.endOf('week').toISODate()!; const prevStart = date.minus({ weeks: 1 }).startOf('week').toISODate()!; const prevEnd = date.minus({ weeks: 1 }).endOf('week').toISODate()!; - const current = rangeStats(repo, standup.id, weekStart, weekEnd); - const previous = rangeStats(repo, standup.id, prevStart, prevEnd); + const current = await rangeStats(repo, standup.id, weekStart, weekEnd); + const previous = await rangeStats(repo, standup.id, prevStart, prevEnd); return { standupName: standup.name, @@ -76,9 +81,9 @@ export function buildWeeklyDigest(repo: Repo, standup: Standup, runDate: string) prevParticipationPct: previous.runCount > 0 ? participationPct(previous) : null, avgMood: avgMood(current), prevAvgMood: previous.runCount > 0 ? avgMood(previous) : null, - blockersOpened: repo.countBlockersOpenedBetween(standup.id, weekStart, weekEnd), - blockersResolved: repo.countBlockersResolvedBetween(standup.id, weekStart, weekEnd), - openBlockers: repo.listOpenBlockers(standup.id).map((b) => ({ + blockersOpened: await repo.countBlockersOpenedBetween(standup.id, weekStart, weekEnd), + blockersResolved: await repo.countBlockersResolvedBetween(standup.id, weekStart, weekEnd), + openBlockers: (await repo.listOpenBlockers(standup.id)).map((b) => ({ displayName: b.displayName, text: b.text, ageDays: Math.max(0, Math.floor(DateTime.fromISO(runDate).diff(DateTime.fromISO(b.openedDate), 'days').days)), @@ -115,13 +120,13 @@ export function digestText(digest: WeeklyDigest): string { return lines.join('\n'); } -export function trendsText(repo: Repo, standup: Standup, now: DateTime, weeks = 4): string { +export async function trendsText(repo: Repo, standup: Standup, now: DateTime, weeks = 4): Promise { const lines = [`📈 *${standup.name}* — last ${weeks} weeks`]; const local = now.setZone(standup.timezone); for (let i = weeks - 1; i >= 0; i--) { const start = local.minus({ weeks: i }).startOf('week'); const end = local.minus({ weeks: i }).endOf('week'); - const stats = rangeStats(repo, standup.id, start.toISODate()!, end.toISODate()!); + const stats = await rangeStats(repo, standup.id, start.toISODate()!, end.toISODate()!); if (stats.runCount === 0) { lines.push(`${start.toFormat('dd LLL')}–${end.toFormat('dd LLL')} ▸ no runs`); continue; @@ -133,7 +138,7 @@ export function trendsText(repo: Repo, standup: Standup, now: DateTime, weeks = (mood !== null ? ` · mood ${moodEmoji(mood)} ${mood}/5` : ''), ); } - const open = repo.listOpenBlockers(standup.id).length; + const open = (await repo.listOpenBlockers(standup.id)).length; if (open > 0) lines.push(`⚠️ ${open} open blocker${open === 1 ? '' : 's'} — try \`blockers\``); return lines.join('\n'); } diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index 4dfcd8d..e088c24 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -47,7 +47,7 @@ export class Scheduler { async tick(): Promise { const now = this.now(); - for (const standup of this.repo.listActiveStandups()) { + for (const standup of await this.repo.listActiveStandups()) { try { await this.tickStandup(standup, now); } catch (err) { @@ -63,7 +63,7 @@ export class Scheduler { // Runs left open from previous days (e.g. downtime past the deadline): // close them so the report still goes out. - for (const stale of this.repo.listOpenRuns(standup.id)) { + for (const stale of await this.repo.listOpenRuns(standup.id)) { if (stale.date < today) await this.closeRun(standup, stale); } @@ -73,11 +73,11 @@ export class Scheduler { const deadlineAt = timeOn(today, standup.deadlineTime, zone); const remindAt = deadlineAt.minus({ minutes: standup.reminderMinutesBefore }); - let run = this.repo.getRun(standup.id, today); + let run = await this.repo.getRun(standup.id, today); if (!run && now >= promptAt) { - const roster = this.repo.listParticipants(standup.id); + const roster = await this.repo.listParticipants(standup.id); if (roster.filter((p) => !p.onVacation).length === 0) return; - run = this.repo.createRun(standup.id, today, `standup-${standup.id}-${today}`); + run = await this.repo.createRun(standup.id, today, `standup-${standup.id}-${today}`); this.log(`opened run ${run.id} for "${standup.name}" ${today}`); await this.applyCalendarOoo(standup, run); try { @@ -89,8 +89,8 @@ export class Scheduler { } if (!run || run.status !== 'open') return; - const submitted = new Set(this.repo.listSubmissions(run.id).map((s) => s.userName)); - const participants = this.repo.listRunParticipants(run.id); + const submitted = new Set((await this.repo.listSubmissions(run.id)).map((s) => s.userName)); + const participants = await this.repo.listRunParticipants(run.id); const skipPrompt = (p: (typeof participants)[number]) => p.onVacation || p.skippedAt !== null || submitted.has(p.userName); @@ -101,7 +101,7 @@ export class Scheduler { if (now < pPromptAt) continue; try { await this.adapter.sendStandupPrompt(rp.userName, standup, run); - this.repo.markPrompted(run.id, rp.userName, now.toISO()!); + await this.repo.markPrompted(run.id, rp.userName, now.toISO()!); } catch (err) { this.log(`prompt to ${rp.userName} failed: ${err}`); } @@ -112,7 +112,7 @@ export class Scheduler { if (rp.remindedAt || !rp.promptedAt || skipPrompt(rp)) continue; try { await this.adapter.sendReminder(rp.userName, standup, run); - this.repo.markReminded(run.id, rp.userName, now.toISO()!); + await this.repo.markReminded(run.id, rp.userName, now.toISO()!); } catch (err) { this.log(`reminder to ${rp.userName} failed: ${err}`); } @@ -125,13 +125,13 @@ export class Scheduler { /** Marks participants with a calendar OOO event today as away for this run only. */ private async applyCalendarOoo(standup: Standup, run: Run): Promise { if (!this.ooo) return; - for (const rp of this.repo.listRunParticipants(run.id)) { + for (const rp of await this.repo.listRunParticipants(run.id)) { if (rp.onVacation) continue; - const email = this.repo.getUserEmail(rp.userName); + const email = await this.repo.getUserEmail(rp.userName); if (!email) continue; try { if (await this.ooo.isOoo(email, run.date, standup.timezone)) { - this.repo.markRunVacation(run.id, rp.userName); + await this.repo.markRunVacation(run.id, rp.userName); this.log(`calendar OOO: ${rp.displayName} is away for run ${run.id}`); } } catch (err) { @@ -141,10 +141,10 @@ export class Scheduler { } private async closeRun(standup: Standup, run: Run): Promise { - this.repo.closeRun(run.id); + await this.repo.closeRun(run.id); this.log(`closed run ${run.id} for "${standup.name}" ${run.date}`); try { - await this.adapter.postSummary(standup, run, this.service.buildSummary(run.id)); + await this.adapter.postSummary(standup, run, await this.service.buildSummary(run.id)); } catch (err) { this.log(`postSummary failed for run ${run.id}: ${err}`); } @@ -159,7 +159,7 @@ export class Scheduler { if (standup.aiEnabled && this.ai) { try { - const submissions = this.repo.listSubmissions(run.id); + const submissions = await this.repo.listSubmissions(run.id); if (submissions.length > 0) { const text = await this.ai.dailySummary(standup, run, submissions); await this.adapter.postText(standup.spaceName, `🤖 *AI summary*\n${text}`, run.threadKey); @@ -173,10 +173,10 @@ export class Scheduler { const weekday = DateTime.fromISO(run.date).weekday; if (weekday === lastConfiguredWeekday(standup)) { try { - const digest = buildWeeklyDigest(this.repo, standup, run.date); + const digest = await buildWeeklyDigest(this.repo, standup, run.date); let text = digestText(digest); if (standup.aiEnabled && this.ai) { - const submissions = this.repo.listSubmissionsBetween(standup.id, digest.weekStart, digest.weekEnd); + const submissions = await this.repo.listSubmissionsBetween(standup.id, digest.weekStart, digest.weekEnd); if (submissions.length > 0) { text += `\n\n🤖 *AI week in review*\n${await this.ai.weeklySummary(standup, digest, submissions)}`; } @@ -192,7 +192,7 @@ export class Scheduler { private async escalateStaleBlockers(standup: Standup, run: Run): Promise { const today = DateTime.fromISO(run.date); - const stale = this.repo.listOpenBlockers(standup.id).filter((b) => { + const stale = (await this.repo.listOpenBlockers(standup.id)).filter((b) => { if (b.escalatedAt) return false; const age = Math.floor(today.diff(DateTime.fromISO(b.openedDate), 'days').days); return age >= standup.escalateAfterDays; @@ -208,7 +208,7 @@ export class Scheduler { `🚨 *${standup.name}*: ${stale.length} blocker${stale.length === 1 ? '' : 's'} open for ${standup.escalateAfterDays}+ days:\n${lines.join('\n')}\nBlockers auto-resolve when the person submits a blocker-free standup.`, ); const at = this.now().toISO()!; - for (const b of stale) this.repo.markBlockerEscalated(b.id, at); + for (const b of stale) await this.repo.markBlockerEscalated(b.id, at); this.log(`escalated ${stale.length} blocker(s) for "${standup.name}" to ${standup.escalateDisplayName}`); } } diff --git a/src/core/standup-service.ts b/src/core/standup-service.ts index 173defb..cda7adf 100644 --- a/src/core/standup-service.ts +++ b/src/core/standup-service.ts @@ -37,26 +37,33 @@ export class StandupService { displayName: string, input: SubmissionInput, ): Promise { - const run = this.repo.getRunById(runId); + const run = await this.repo.getRunById(runId); if (!run) return { ok: false, reason: 'run_not_found' }; - const isParticipant = this.repo.listRunParticipants(run.id).some((p) => p.userName === userName); - if (!isParticipant) return { ok: false, reason: 'not_a_participant' }; + const roster = await this.repo.listRunParticipants(run.id); + if (!roster.some((p) => p.userName === userName)) { + return { ok: false, reason: 'not_a_participant' }; + } - const standup = this.repo.getStandupById(run.standupId)!; - const existing = this.repo.getSubmission(run.id, userName); + const standup = (await this.repo.getStandupById(run.standupId))!; + const existing = await this.repo.getSubmission(run.id, userName); if (existing) { if (run.status === 'closed') return { ok: false, reason: 'already_submitted' }; - const updated = this.repo.updateSubmission(existing.id, input.answers, input.mood, this.now().toISO()!); - this.repo.deleteBlockersOpenedBy(run.id, userName); - this.trackBlockers(standup, run, userName, displayName, input, { resolveOthers: false }); + const updated = await this.repo.updateSubmission( + existing.id, + input.answers, + input.mood, + this.now().toISO()!, + ); + await this.repo.deleteBlockersOpenedBy(run.id, userName); + await this.trackBlockers(standup, run, userName, displayName, input, { resolveOthers: false }); if (updated.messageName) await this.adapter.updateSubmission(standup, updated); return { ok: true, late: false, edited: true }; } const late = run.status === 'closed'; - const submission = this.repo.createSubmission({ + const submission = await this.repo.createSubmission({ runId: run.id, userName, displayName, @@ -65,62 +72,69 @@ export class StandupService { late, submittedAt: this.now().toISO()!, }); - this.trackBlockers(standup, run, userName, displayName, input, { resolveOthers: true }); + await this.trackBlockers(standup, run, userName, displayName, input, { resolveOthers: true }); const messageName = await this.adapter.postSubmission(standup, run, submission); - if (messageName) this.repo.setSubmissionMessageName(submission.id, messageName); + if (messageName) await this.repo.setSubmissionMessageName(submission.id, messageName); return { ok: true, late, edited: false }; } - private trackBlockers( + private async trackBlockers( standup: Standup, run: Run, userName: string, displayName: string, input: SubmissionInput, opts: { resolveOthers: boolean }, - ): void { + ): Promise { const texts = blockerAnswers(input); for (const text of texts) { - this.repo.openBlocker({ standupId: standup.id, userName, displayName, text, runId: run.id, date: run.date }); + await this.repo.openBlocker({ + standupId: standup.id, + userName, + displayName, + text, + runId: run.id, + date: run.date, + }); } // A blocker-free submission resolves the person's previously open blockers. if (texts.length === 0 && opts.resolveOthers) { - this.repo.resolveBlockersFor(standup.id, userName, run.id, run.date); + await this.repo.resolveBlockersFor(standup.id, userName, run.id, run.date); } } /** Mark a participant as sitting out today's run (no effect once submitted). */ - skipToday(runId: number, userName: string): SkipResult { - const run = this.repo.getRunById(runId); + async skipToday(runId: number, userName: string): Promise { + const run = await this.repo.getRunById(runId); if (!run) return 'not_found'; - if (this.repo.getSubmission(run.id, userName)) return 'already_submitted'; - return this.repo.markSkipped(run.id, userName, this.now().toISO()!) ? 'skipped' : 'not_found'; + if (await this.repo.getSubmission(run.id, userName)) return 'already_submitted'; + return (await this.repo.markSkipped(run.id, userName, this.now().toISO()!)) ? 'skipped' : 'not_found'; } /** * Prefill values aligned with the standup's questions: "yesterday"-style * questions get the user's previous answer to the "today"-style question. */ - getPrefill(standup: Standup, run: Run, userName: string): string[] { + async getPrefill(standup: Standup, run: Run, userName: string): Promise { const questions = standupQuestions(standup); - const existing = this.repo.getSubmission(run.id, userName); + const existing = await this.repo.getSubmission(run.id, userName); if (existing) { // Editing: prefill with what they already submitted. return questions.map((q) => existing.answers.find((a) => a.question === q)?.answer ?? ''); } - const previous = this.repo.getPreviousSubmission(standup.id, userName, run.id); + const previous = await this.repo.getPreviousSubmission(standup.id, userName, run.id); if (!previous) return questions.map(() => ''); const prevToday = previous.answers.find((a) => isTodayQuestion(a.question))?.answer ?? ''; return questions.map((q) => (isYesterdayQuestion(q) ? prevToday : '')); } - buildSummary(runId: number): RunSummary { - const run = this.repo.getRunById(runId); + async buildSummary(runId: number): Promise { + const run = await this.repo.getRunById(runId); if (!run) throw new Error(`run ${runId} not found`); - const standup = this.repo.getStandupById(run.standupId)!; - const participants = this.repo.listRunParticipants(run.id); - const submissions = this.repo.listSubmissions(run.id); + const standup = (await this.repo.getStandupById(run.standupId))!; + const participants = await this.repo.listRunParticipants(run.id); + const submissions = await this.repo.listSubmissions(run.id); const submittedBy = new Set(submissions.map((s) => s.userName)); const mandatory = participants.filter((p) => p.mandatory); @@ -153,7 +167,7 @@ export class StandupService { (s) => !mandatory.some((p) => p.userName === s.userName), ).length, lateCount: submissions.filter((s) => s.late).length, - openBlockers: this.repo.listOpenBlockers(standup.id).length, + openBlockers: (await this.repo.listOpenBlockers(standup.id)).length, teamMood, }; } diff --git a/src/dashboard/dashboard.ts b/src/dashboard/dashboard.ts index 9cf8e94..bc1aa83 100644 --- a/src/dashboard/dashboard.ts +++ b/src/dashboard/dashboard.ts @@ -44,25 +44,25 @@ export function registerDashboard(app: Express, deps: DashboardDeps): void { return false; }; - app.get('/dashboard', (req, res) => { + app.get('/dashboard', async (req, res) => { if (!authed(req, res)) return; - const standups = repo.listActiveStandups(); - const rows = standups - .map((s) => { - const today = now().setZone(s.timezone).toISODate()!; - const run = repo.getRun(s.id, today); - const todayCell = run - ? `${repo.listSubmissions(run.id).length} submitted (${run.status})` - : '—'; - return ` + const standups = await repo.listActiveStandups(); + const rowParts: string[] = []; + for (const s of standups) { + const today = now().setZone(s.timezone).toISODate()!; + const run = await repo.getRun(s.id, today); + const todayCell = run + ? `${(await repo.listSubmissions(run.id)).length} submitted (${run.status})` + : '—'; + rowParts.push(` #${s.id} ${esc(s.name)} ${esc(s.spaceName)} ${esc(s.promptTime)} → ${esc(s.deadlineTime)} ${esc(s.timezone)} - ${repo.listParticipants(s.id).length} + ${(await repo.listParticipants(s.id)).length} ${todayCell} - `; - }) - .join(''); + `); + } + const rows = rowParts.join(''); res.send( layout( 'AsyncUp dashboard', @@ -73,40 +73,40 @@ export function registerDashboard(app: Express, deps: DashboardDeps): void { ); }); - app.get('/dashboard/standup/:id', (req, res) => { + app.get('/dashboard/standup/:id', async (req, res) => { if (!authed(req, res)) return; - const standup = repo.getStandupById(Number(req.params.id)); + const standup = await repo.getStandupById(Number(req.params.id)); if (!standup) { res.status(404).send(layout('Not found', '

Unknown standup.

')); return; } - res.send(layout(`${standup.name} — AsyncUp`, standupPage(repo, standup, now(), req.query.saved === '1', null))); + res.send(layout(`${standup.name} — AsyncUp`, await standupPage(repo, standup, now(), req.query.saved === '1', null))); }); - app.post('/dashboard/standup/:id', (req, res) => { + app.post('/dashboard/standup/:id', async (req, res) => { if (!authed(req, res)) return; - const standup = repo.getStandupById(Number(req.params.id)); + const standup = await repo.getStandupById(Number(req.params.id)); if (!standup) { res.status(404).send(layout('Not found', '

Unknown standup.

')); return; } - const error = applyConfig(repo, standup, req.body); + const error = await applyConfig(repo, standup, req.body); if (error) { - res.status(400).send(layout(`${standup.name} — AsyncUp`, standupPage(repo, repo.getStandupById(standup.id)!, now(), false, error))); + res.status(400).send(layout(`${standup.name} — AsyncUp`, await standupPage(repo, (await repo.getStandupById(standup.id))!, now(), false, error))); return; } res.redirect(`/dashboard/standup/${standup.id}?saved=1`); }); - app.get('/dashboard/standup/:id/run/:date', (req, res) => { + app.get('/dashboard/standup/:id/run/:date', async (req, res) => { if (!authed(req, res)) return; - const standup = repo.getStandupById(Number(req.params.id)); - const run = standup ? repo.getRun(standup.id, String(req.params.date)) : null; + const standup = await repo.getStandupById(Number(req.params.id)); + const run = standup ? await repo.getRun(standup.id, String(req.params.date)) : null; if (!standup || !run) { res.status(404).send(layout('Not found', '

Unknown run.

')); return; } - const submissions = repo.listSubmissions(run.id) + const submissions = (await repo.listSubmissions(run.id)) .map( (s) => `

${s.mood && !standup.moodAnonymous ? MOOD_EMOJI[s.mood] : '📝'} ${esc(s.displayName)} @@ -115,8 +115,8 @@ export function registerDashboard(app: Express, deps: DashboardDeps): void {

`, ) .join(''); - const roster = repo.listRunParticipants(run.id); - const submitted = new Set(repo.listSubmissions(run.id).map((s) => s.userName)); + const roster = await repo.listRunParticipants(run.id); + const submitted = new Set((await repo.listSubmissions(run.id)).map((s) => s.userName)); const missing = roster .filter((p) => p.mandatory && !submitted.has(p.userName) && !p.skippedAt && !p.onVacation) .map((p) => esc(p.displayName)); @@ -132,7 +132,7 @@ export function registerDashboard(app: Express, deps: DashboardDeps): void { }); } -function applyConfig(repo: Repo, standup: Standup, body: any): string | null { +async function applyConfig(repo: Repo, standup: Standup, body: any): Promise { const name = String(body.name ?? '').trim(); if (!name) return 'Name is required.'; const promptTime = String(body.promptTime ?? ''); @@ -161,7 +161,7 @@ function applyConfig(repo: Repo, standup: Standup, body: any): string | null { if (questionLines.length === 0 || questionLines.length > 10) return 'Provide 1–10 questions (one per line).'; if (questionLines.some((q: string) => q.length > 200)) return 'Questions must be ≤200 characters.'; - repo.updateStandup(standup.id, { + await repo.updateStandup(standup.id, { name, promptTime, deadlineTime, @@ -178,48 +178,51 @@ function applyConfig(repo: Repo, standup: Standup, body: any): string | null { return null; } -function standupPage(repo: Repo, s: Standup, now: DateTime, saved: boolean, error: string | null): string { - const participants = repo.listParticipants(s.id) +async function standupPage(repo: Repo, s: Standup, now: DateTime, saved: boolean, error: string | null): Promise { + const participants = (await repo.listParticipants(s.id)) .map( (p) => `
  • ${esc(p.displayName)}${p.mandatory ? '' : ' optional'}${p.onVacation ? ' 🏖️' : ''}
  • `, ) .join(''); - const admins = repo.listAdmins(s.id).map((a) => esc(a.displayName)).join(', ') || 'none (open config)'; + const admins = (await repo.listAdmins(s.id)).map((a) => esc(a.displayName)).join(', ') || 'none (open config)'; - const runs = repo.listRecentRuns(s.id, 14) - .map((run) => { - const roster = repo.listRunParticipants(run.id); - const submitted = new Set(repo.listSubmissions(run.id).map((x) => x.userName)); - const away = roster.filter((p) => !submitted.has(p.userName) && (p.skippedAt || p.onVacation)); - const missing = roster.filter( - (p) => p.mandatory && !submitted.has(p.userName) && !p.skippedAt && !p.onVacation, - ); - return ` + const runParts: string[] = []; + for (const run of await repo.listRecentRuns(s.id, 14)) { + const roster = await repo.listRunParticipants(run.id); + const submitted = new Set((await repo.listSubmissions(run.id)).map((x) => x.userName)); + const away = roster.filter((p) => !submitted.has(p.userName) && (p.skippedAt || p.onVacation)); + const missing = roster.filter( + (p) => p.mandatory && !submitted.has(p.userName) && !p.skippedAt && !p.onVacation, + ); + runParts.push(` ${run.date} ${run.status} ${submitted.size}/${roster.length - away.length} ${missing.map((p) => esc(p.displayName)).join(', ') || '—'} - `; - }) - .join(''); + `); + } + const runs = runParts.join(''); const local = now.setZone(s.timezone); - const trendRows = [3, 2, 1, 0] - .map((i) => { - const start = local.minus({ weeks: i }).startOf('week'); - const end = local.minus({ weeks: i }).endOf('week'); - const stats = rangeStats(repo, s.id, start.toISODate()!, end.toISODate()!); - if (stats.runCount === 0) return `${start.toFormat('dd LLL')}no runs`; - const pct = stats.expected === 0 ? 100 : Math.round((stats.submitted / stats.expected) * 100); - const mood = stats.moodCount ? Math.round((stats.moodSum / stats.moodCount) * 10) / 10 : null; - return `${start.toFormat('dd LLL')}–${end.toFormat('dd LLL')}${pct}%${ - mood !== null ? `${moodEmoji(mood)} ${mood}/5` : '—' - }`; - }) - .join(''); + const trendParts: string[] = []; + for (const i of [3, 2, 1, 0]) { + const start = local.minus({ weeks: i }).startOf('week'); + const end = local.minus({ weeks: i }).endOf('week'); + const stats = await rangeStats(repo, s.id, start.toISODate()!, end.toISODate()!); + if (stats.runCount === 0) { + trendParts.push(`${start.toFormat('dd LLL')}no runs`); + continue; + } + const pct = stats.expected === 0 ? 100 : Math.round((stats.submitted / stats.expected) * 100); + const mood = stats.moodCount ? Math.round((stats.moodSum / stats.moodCount) * 10) / 10 : null; + trendParts.push(`${start.toFormat('dd LLL')}–${end.toFormat('dd LLL')}${pct}%${ + mood !== null ? `${moodEmoji(mood)} ${mood}/5` : '—' + }`); + } + const trendRows = trendParts.join(''); - const blockers = repo.listOpenBlockers(s.id) + const blockers = (await repo.listOpenBlockers(s.id)) .map((b) => `
  • ⚠️ ${esc(b.displayName)}: ${esc(b.text)} (since ${b.openedDate}${b.escalatedAt ? ', escalated' : ''})
  • `) .join(''); diff --git a/src/db/driver.ts b/src/db/driver.ts new file mode 100644 index 0000000..8e35f00 --- /dev/null +++ b/src/db/driver.ts @@ -0,0 +1,179 @@ +import Database from 'better-sqlite3'; +import pg from 'pg'; + +/** + * Thin async database abstraction so the Repo works against embedded SQLite + * (default) or a bring-your-own PostgreSQL (DATABASE_URL). SQL is written + * with `?` placeholders and 0/1 integer booleans in both dialects. + * + * Concurrency model: all operations run through a serializing queue. + * transaction() holds the queue for its whole body while inner operations + * bypass it (reentrancy flag), so a BEGIN/COMMIT pair can never interleave + * with queries from other requests. AsyncUp's traffic is tiny — a single + * serialized connection is plenty and keeps both dialects identical. + */ +export interface Driver { + dialect: 'sqlite' | 'postgres'; + all(sql: string, params?: unknown[]): Promise; + get(sql: string, params?: unknown[]): Promise; + run(sql: string, params?: unknown[]): Promise<{ changes: number }>; + /** INSERT into a table with an `id` column; returns the new id. */ + insert(sql: string, params?: unknown[]): Promise; + exec(sql: string): Promise; + transaction(fn: () => Promise): Promise; + getVersion(): Promise; + setVersion(version: number): Promise; + close(): Promise; +} + +abstract class QueuedDriver { + private queue: Promise = Promise.resolve(); + private inTransaction = false; + + protected dispatch(op: () => Promise): Promise { + if (this.inTransaction) return op(); + const next = this.queue.then(op, op); + this.queue = next.catch(() => {}); + return next; + } + + protected abstract execRaw(sql: string): Promise; + + async transaction(fn: () => Promise): Promise { + return this.dispatch(async () => { + this.inTransaction = true; + try { + await this.execRaw('BEGIN'); + const result = await fn(); + await this.execRaw('COMMIT'); + return result; + } catch (err) { + await this.execRaw('ROLLBACK').catch(() => {}); + throw err; + } finally { + this.inTransaction = false; + } + }); + } +} + +export class SqliteDriver extends QueuedDriver implements Driver { + readonly dialect = 'sqlite' as const; + private db: Database.Database; + + constructor(dbPath: string) { + super(); + this.db = new Database(dbPath); + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + } + + protected async execRaw(sql: string): Promise { + this.db.exec(sql); + } + + async all(sql: string, params: unknown[] = []): Promise { + return this.dispatch(async () => this.db.prepare(sql).all(...params)); + } + + async get(sql: string, params: unknown[] = []): Promise { + return this.dispatch(async () => this.db.prepare(sql).get(...params)); + } + + async run(sql: string, params: unknown[] = []): Promise<{ changes: number }> { + return this.dispatch(async () => ({ changes: this.db.prepare(sql).run(...params).changes })); + } + + async insert(sql: string, params: unknown[] = []): Promise { + return this.dispatch(async () => Number(this.db.prepare(sql).run(...params).lastInsertRowid)); + } + + async exec(sql: string): Promise { + await this.dispatch(() => this.execRaw(sql)); + } + + async getVersion(): Promise { + return this.dispatch(async () => this.db.pragma('user_version', { simple: true }) as number); + } + + async setVersion(version: number): Promise { + await this.dispatch(async () => { + this.db.pragma(`user_version = ${version}`); + }); + } + + async close(): Promise { + this.db.close(); + } +} + +function toPgPlaceholders(sql: string): string { + let i = 0; + return sql.replace(/\?/g, () => `$${++i}`); +} + +export class PostgresDriver extends QueuedDriver implements Driver { + readonly dialect = 'postgres' as const; + private client: pg.Client; + + private constructor(client: pg.Client) { + super(); + this.client = client; + } + + /** `schema` isolates installs/tests sharing one database. */ + static async connect(url: string, schema?: string): Promise { + const client = new pg.Client({ connectionString: url }); + await client.connect(); + if (schema) { + if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error(`invalid schema name: ${schema}`); + await client.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`); + await client.query(`SET search_path TO ${schema}`); + } + await client.query('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER NOT NULL)'); + return new PostgresDriver(client); + } + + protected async execRaw(sql: string): Promise { + await this.client.query(sql); + } + + async all(sql: string, params: unknown[] = []): Promise { + return this.dispatch(async () => (await this.client.query(toPgPlaceholders(sql), params)).rows); + } + + async get(sql: string, params: unknown[] = []): Promise { + return this.dispatch(async () => (await this.client.query(toPgPlaceholders(sql), params)).rows[0]); + } + + async run(sql: string, params: unknown[] = []): Promise<{ changes: number }> { + return this.dispatch(async () => ({ + changes: (await this.client.query(toPgPlaceholders(sql), params)).rowCount ?? 0, + })); + } + + async insert(sql: string, params: unknown[] = []): Promise { + return this.dispatch(async () => { + const res = await this.client.query(`${toPgPlaceholders(sql)} RETURNING id`, params); + return Number(res.rows[0].id); + }); + } + + async exec(sql: string): Promise { + await this.dispatch(() => this.execRaw(sql)); + } + + async getVersion(): Promise { + const row = await this.get('SELECT version FROM schema_migrations LIMIT 1'); + return row?.version ?? 0; + } + + async setVersion(version: number): Promise { + await this.run('DELETE FROM schema_migrations'); + await this.run('INSERT INTO schema_migrations (version) VALUES (?)', [version]); + } + + async close(): Promise { + await this.client.end(); + } +} diff --git a/src/db/repo.ts b/src/db/repo.ts index 9512b19..7c5a907 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -1,4 +1,4 @@ -import Database from 'better-sqlite3'; +import { PostgresDriver, SqliteDriver, type Driver } from './driver.js'; import type { Admin, Answer, @@ -13,10 +13,10 @@ import type { } from '../core/types.js'; /** - * Versioned migrations tracked via PRAGMA user_version. - * Never edit an existing entry — append a new one. + * SQLite migrations tracked via PRAGMA user_version. + * Never edit an existing entry — append a new one (and mirror it for Postgres). */ -const MIGRATIONS: string[] = [ +const SQLITE_MIGRATIONS: string[] = [ // 1 — initial schema (v0.1) ` CREATE TABLE IF NOT EXISTS standups ( @@ -168,6 +168,112 @@ ALTER TABLE blockers ADD COLUMN escalated_at TEXT; `, ]; +/** + * Postgres installs are new as of schema v3, so migration 1 is the full + * current schema. Future migrations append to BOTH dialect arrays and the + * version numbers stay aligned via padding entries. + */ +const POSTGRES_MIGRATIONS: string[] = [ + ` +CREATE TABLE standups ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id TEXT NOT NULL, + space_name TEXT NOT NULL, + name TEXT NOT NULL, + prompt_time TEXT NOT NULL DEFAULT '09:30', + deadline_time TEXT NOT NULL DEFAULT '11:30', + reminder_minutes_before INTEGER NOT NULL DEFAULT 60, + timezone TEXT NOT NULL, + days TEXT NOT NULL DEFAULT 'mon,tue,wed,thu,fri', + questions TEXT, + mood_enabled INTEGER NOT NULL DEFAULT 1, + mood_anonymous INTEGER NOT NULL DEFAULT 0, + digest_enabled INTEGER NOT NULL DEFAULT 0, + ai_enabled INTEGER NOT NULL DEFAULT 0, + escalate_user_name TEXT, + escalate_display_name TEXT, + escalate_after_days INTEGER NOT NULL DEFAULT 2, + active INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_standups_space ON standups(tenant_id, space_name); +CREATE TABLE participants ( + standup_id INTEGER NOT NULL REFERENCES standups(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + timezone TEXT, + mandatory INTEGER NOT NULL DEFAULT 1, + on_vacation INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (standup_id, user_name) +); +CREATE TABLE runs ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + standup_id INTEGER NOT NULL REFERENCES standups(id), + date TEXT NOT NULL, + thread_key TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (standup_id, date) +); +CREATE TABLE run_participants ( + run_id INTEGER NOT NULL REFERENCES runs(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + timezone TEXT, + mandatory INTEGER NOT NULL, + on_vacation INTEGER NOT NULL DEFAULT 0, + prompted_at TEXT, + reminded_at TEXT, + skipped_at TEXT, + PRIMARY KEY (run_id, user_name) +); +CREATE TABLE submissions ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + run_id INTEGER NOT NULL REFERENCES runs(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + answers TEXT NOT NULL, + mood TEXT, + late INTEGER NOT NULL DEFAULT 0, + submitted_at TEXT NOT NULL, + edited_at TEXT, + message_name TEXT, + UNIQUE (run_id, user_name) +); +CREATE TABLE dm_spaces ( + user_name TEXT PRIMARY KEY, + space_name TEXT NOT NULL +); +CREATE TABLE standup_admins ( + standup_id INTEGER NOT NULL REFERENCES standups(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + PRIMARY KEY (standup_id, user_name) +); +CREATE TABLE blockers ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + standup_id INTEGER NOT NULL REFERENCES standups(id), + user_name TEXT NOT NULL, + display_name TEXT NOT NULL, + text TEXT NOT NULL, + opened_run_id INTEGER NOT NULL REFERENCES runs(id), + opened_date TEXT NOT NULL, + resolved_run_id INTEGER REFERENCES runs(id), + resolved_date TEXT, + escalated_at TEXT +); +CREATE INDEX idx_blockers_open ON blockers(standup_id) WHERE resolved_run_id IS NULL; +CREATE TABLE user_emails ( + user_name TEXT PRIMARY KEY, + email TEXT NOT NULL +); +`, + // 2, 3 — already included in the initial Postgres schema above + '', + '', +]; + function toStandup(row: any): Standup { return { id: row.id, @@ -258,76 +364,83 @@ function toBlocker(row: any): Blocker { } export class Repo { - private db: Database.Database; - - constructor(dbPath: string) { - this.db = new Database(dbPath); - this.db.pragma('journal_mode = WAL'); - this.db.pragma('foreign_keys = ON'); - this.migrate(); - } - - private migrate(): void { - let version = this.db.pragma('user_version', { simple: true }) as number; - // v0.1 databases predate versioning but already have the initial schema. - if (version === 0) { - const existing = this.db - .prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'standups'`) - .get(); + private constructor(private db: Driver) {} + + /** Embedded SQLite — the zero-config default. */ + static async sqlite(dbPath: string): Promise { + const repo = new Repo(new SqliteDriver(dbPath)); + await repo.migrate(SQLITE_MIGRATIONS, true); + return repo; + } + + /** Bring-your-own PostgreSQL via connection string. */ + static async postgres(url: string, schema?: string): Promise { + const repo = new Repo(await PostgresDriver.connect(url, schema)); + await repo.migrate(POSTGRES_MIGRATIONS, false); + return repo; + } + + private async migrate(migrations: string[], detectLegacy: boolean): Promise { + let version = await this.db.getVersion(); + // v0.1 SQLite databases predate versioning but already have the initial schema. + if (detectLegacy && version === 0) { + const existing = await this.db.get( + `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'standups'`, + ); if (existing) { version = 1; - this.db.pragma('user_version = 1'); + await this.db.setVersion(1); } } - for (let i = version; i < MIGRATIONS.length; i++) { - this.db.transaction(() => { - this.db.exec(MIGRATIONS[i]!); - this.db.pragma(`user_version = ${i + 1}`); - })(); + for (let i = version; i < migrations.length; i++) { + await this.db.transaction(async () => { + if (migrations[i]!.trim()) await this.db.exec(migrations[i]!); + await this.db.setVersion(i + 1); + }); } } - close(): void { - this.db.close(); + async ping(): Promise { + return !!(await this.db.get('SELECT 1 AS ok')); + } + + async close(): Promise { + await this.db.close(); } // --- standups --- - createStandup(input: { + async createStandup(input: { tenantId: string; spaceName: string; name: string; timezone: string; - }): Standup { - const result = this.db - .prepare( - `INSERT INTO standups (tenant_id, space_name, name, timezone) - VALUES (@tenantId, @spaceName, @name, @timezone)`, - ) - .run(input); - return this.getStandupById(Number(result.lastInsertRowid))!; - } - - getStandupById(id: number): Standup | null { - const row = this.db.prepare('SELECT * FROM standups WHERE id = ?').get(id); + }): Promise { + const id = await this.db.insert( + 'INSERT INTO standups (tenant_id, space_name, name, timezone) VALUES (?, ?, ?, ?)', + [input.tenantId, input.spaceName, input.name, input.timezone], + ); + return (await this.getStandupById(id))!; + } + + async getStandupById(id: number): Promise { + const row = await this.db.get('SELECT * FROM standups WHERE id = ?', [id]); return row ? toStandup(row) : null; } - listStandupsBySpace(tenantId: string, spaceName: string): Standup[] { - return this.db - .prepare('SELECT * FROM standups WHERE tenant_id = ? AND space_name = ? AND active = 1 ORDER BY id') - .all(tenantId, spaceName) - .map(toStandup); + async listStandupsBySpace(tenantId: string, spaceName: string): Promise { + const rows = await this.db.all( + 'SELECT * FROM standups WHERE tenant_id = ? AND space_name = ? AND active = 1 ORDER BY id', + [tenantId, spaceName], + ); + return rows.map(toStandup); } - listActiveStandups(): Standup[] { - return this.db - .prepare('SELECT * FROM standups WHERE active = 1') - .all() - .map(toStandup); + async listActiveStandups(): Promise { + return (await this.db.all('SELECT * FROM standups WHERE active = 1')).map(toStandup); } - updateStandup( + async updateStandup( id: number, fields: Partial< Pick< @@ -349,7 +462,7 @@ export class Repo { | 'active' > >, - ): void { + ): Promise { const mapping: Record = { name: 'name', promptTime: 'prompt_time', @@ -377,203 +490,227 @@ export class Repo { } if (sets.length === 0) return; values.push(id); - this.db.prepare(`UPDATE standups SET ${sets.join(', ')} WHERE id = ?`).run(...values); + await this.db.run(`UPDATE standups SET ${sets.join(', ')} WHERE id = ?`, values); } // --- participants --- - upsertParticipant(input: { + async upsertParticipant(input: { standupId: number; userName: string; displayName: string; mandatory?: boolean; - }): void { - this.db - .prepare( - `INSERT INTO participants (standup_id, user_name, display_name, mandatory, active) - VALUES (@standupId, @userName, @displayName, @mandatory, 1) - ON CONFLICT (standup_id, user_name) - DO UPDATE SET display_name = @displayName, active = 1`, - ) - .run({ ...input, mandatory: input.mandatory === false ? 0 : 1 }); - } - - setParticipantMandatory(standupId: number, userName: string, mandatory: boolean): boolean { - const result = this.db - .prepare('UPDATE participants SET mandatory = ? WHERE standup_id = ? AND user_name = ? AND active = 1') - .run(mandatory ? 1 : 0, standupId, userName); + }): Promise { + await this.db.run( + `INSERT INTO participants (standup_id, user_name, display_name, mandatory, active) + VALUES (?, ?, ?, ?, 1) + ON CONFLICT (standup_id, user_name) + DO UPDATE SET display_name = excluded.display_name, active = 1`, + [input.standupId, input.userName, input.displayName, input.mandatory === false ? 0 : 1], + ); + } + + async setParticipantMandatory(standupId: number, userName: string, mandatory: boolean): Promise { + const result = await this.db.run( + 'UPDATE participants SET mandatory = ? WHERE standup_id = ? AND user_name = ? AND active = 1', + [mandatory ? 1 : 0, standupId, userName], + ); return result.changes > 0; } - setParticipantVacation(standupId: number, userName: string, onVacation: boolean): boolean { - const result = this.db - .prepare('UPDATE participants SET on_vacation = ? WHERE standup_id = ? AND user_name = ? AND active = 1') - .run(onVacation ? 1 : 0, standupId, userName); + async setParticipantVacation(standupId: number, userName: string, onVacation: boolean): Promise { + const result = await this.db.run( + 'UPDATE participants SET on_vacation = ? WHERE standup_id = ? AND user_name = ? AND active = 1', + [onVacation ? 1 : 0, standupId, userName], + ); return result.changes > 0; } /** DM self-service: toggles vacation in every standup the user is part of. */ - setVacationForUser(userName: string, onVacation: boolean): number { - return this.db - .prepare('UPDATE participants SET on_vacation = ? WHERE user_name = ? AND active = 1') - .run(onVacation ? 1 : 0, userName).changes; + async setVacationForUser(userName: string, onVacation: boolean): Promise { + const result = await this.db.run( + 'UPDATE participants SET on_vacation = ? WHERE user_name = ? AND active = 1', + [onVacation ? 1 : 0, userName], + ); + return result.changes; } - setParticipantTimezone(standupId: number, userName: string, timezone: string | null): boolean { - const result = this.db - .prepare('UPDATE participants SET timezone = ? WHERE standup_id = ? AND user_name = ? AND active = 1') - .run(timezone, standupId, userName); + async setParticipantTimezone(standupId: number, userName: string, timezone: string | null): Promise { + const result = await this.db.run( + 'UPDATE participants SET timezone = ? WHERE standup_id = ? AND user_name = ? AND active = 1', + [timezone, standupId, userName], + ); return result.changes > 0; } - removeParticipant(standupId: number, userName: string): boolean { - const result = this.db - .prepare('UPDATE participants SET active = 0 WHERE standup_id = ? AND user_name = ?') - .run(standupId, userName); + async removeParticipant(standupId: number, userName: string): Promise { + const result = await this.db.run( + 'UPDATE participants SET active = 0 WHERE standup_id = ? AND user_name = ?', + [standupId, userName], + ); return result.changes > 0; } - listParticipants(standupId: number): Participant[] { - return this.db - .prepare('SELECT * FROM participants WHERE standup_id = ? AND active = 1 ORDER BY display_name') - .all(standupId) - .map(toParticipant); + async listParticipants(standupId: number): Promise { + const rows = await this.db.all( + 'SELECT * FROM participants WHERE standup_id = ? AND active = 1 ORDER BY display_name', + [standupId], + ); + return rows.map(toParticipant); } /** Standups (across tenants) in which the user is an active participant. */ - listStandupsForUser(userName: string): Standup[] { - return this.db - .prepare( - `SELECT s.* FROM standups s - JOIN participants p ON p.standup_id = s.id - WHERE p.user_name = ? AND p.active = 1 AND s.active = 1`, - ) - .all(userName) - .map(toStandup); + async listStandupsForUser(userName: string): Promise { + const rows = await this.db.all( + `SELECT s.* FROM standups s + JOIN participants p ON p.standup_id = s.id + WHERE p.user_name = ? AND p.active = 1 AND s.active = 1`, + [userName], + ); + return rows.map(toStandup); } // --- admins --- - addAdmin(standupId: number, userName: string, displayName: string): void { - this.db - .prepare( - `INSERT INTO standup_admins (standup_id, user_name, display_name) VALUES (?, ?, ?) - ON CONFLICT (standup_id, user_name) DO UPDATE SET display_name = excluded.display_name`, - ) - .run(standupId, userName, displayName); + async addAdmin(standupId: number, userName: string, displayName: string): Promise { + await this.db.run( + `INSERT INTO standup_admins (standup_id, user_name, display_name) VALUES (?, ?, ?) + ON CONFLICT (standup_id, user_name) DO UPDATE SET display_name = excluded.display_name`, + [standupId, userName, displayName], + ); } - removeAdmin(standupId: number, userName: string): boolean { - return ( - this.db - .prepare('DELETE FROM standup_admins WHERE standup_id = ? AND user_name = ?') - .run(standupId, userName).changes > 0 + async removeAdmin(standupId: number, userName: string): Promise { + const result = await this.db.run( + 'DELETE FROM standup_admins WHERE standup_id = ? AND user_name = ?', + [standupId, userName], ); + return result.changes > 0; } - listAdmins(standupId: number): Admin[] { - return this.db - .prepare('SELECT * FROM standup_admins WHERE standup_id = ? ORDER BY display_name') - .all(standupId) - .map((row: any) => ({ - standupId: row.standup_id, - userName: row.user_name, - displayName: row.display_name, - })); + async listAdmins(standupId: number): Promise { + const rows = await this.db.all( + 'SELECT * FROM standup_admins WHERE standup_id = ? ORDER BY display_name', + [standupId], + ); + return rows.map((row: any) => ({ + standupId: row.standup_id, + userName: row.user_name, + displayName: row.display_name, + })); } - isAdmin(standupId: number, userName: string): boolean { - return !!this.db - .prepare('SELECT 1 FROM standup_admins WHERE standup_id = ? AND user_name = ?') - .get(standupId, userName); + async isAdmin(standupId: number, userName: string): Promise { + return !!(await this.db.get( + 'SELECT 1 FROM standup_admins WHERE standup_id = ? AND user_name = ?', + [standupId, userName], + )); } // --- runs --- /** Creates the run and snapshots the active roster atomically. */ - createRun(standupId: number, date: string, threadKey: string): Run { - const createTx = this.db.transaction(() => { - const result = this.db - .prepare(`INSERT INTO runs (standup_id, date, thread_key) VALUES (?, ?, ?)`) - .run(standupId, date, threadKey); - const runId = Number(result.lastInsertRowid); - const insert = this.db.prepare( - `INSERT INTO run_participants (run_id, user_name, display_name, timezone, mandatory, on_vacation) - VALUES (?, ?, ?, ?, ?, ?)`, + async createRun(standupId: number, date: string, threadKey: string): Promise { + const runId = await this.db.transaction(async () => { + const id = await this.db.insert( + 'INSERT INTO runs (standup_id, date, thread_key) VALUES (?, ?, ?)', + [standupId, date, threadKey], ); - for (const p of this.listParticipants(standupId)) { - insert.run(runId, p.userName, p.displayName, p.timezone, p.mandatory ? 1 : 0, p.onVacation ? 1 : 0); + for (const p of await this.listParticipants(standupId)) { + await this.db.run( + `INSERT INTO run_participants (run_id, user_name, display_name, timezone, mandatory, on_vacation) + VALUES (?, ?, ?, ?, ?, ?)`, + [id, p.userName, p.displayName, p.timezone, p.mandatory ? 1 : 0, p.onVacation ? 1 : 0], + ); } - return runId; + return id; }); - return this.getRunById(createTx())!; + return (await this.getRunById(runId))!; } - getRunById(id: number): Run | null { - const row = this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id); + async getRunById(id: number): Promise { + const row = await this.db.get('SELECT * FROM runs WHERE id = ?', [id]); return row ? toRun(row) : null; } - getRun(standupId: number, date: string): Run | null { - const row = this.db.prepare('SELECT * FROM runs WHERE standup_id = ? AND date = ?').get(standupId, date); + async getRun(standupId: number, date: string): Promise { + const row = await this.db.get('SELECT * FROM runs WHERE standup_id = ? AND date = ?', [ + standupId, + date, + ]); return row ? toRun(row) : null; } - listOpenRuns(standupId: number): Run[] { - return this.db - .prepare(`SELECT * FROM runs WHERE standup_id = ? AND status = 'open'`) - .all(standupId) - .map(toRun); + async listOpenRuns(standupId: number): Promise { + const rows = await this.db.all(`SELECT * FROM runs WHERE standup_id = ? AND status = 'open'`, [ + standupId, + ]); + return rows.map(toRun); } - listRunsBetween(standupId: number, fromDate: string, toDate: string): Run[] { - return this.db - .prepare('SELECT * FROM runs WHERE standup_id = ? AND date >= ? AND date <= ? ORDER BY date') - .all(standupId, fromDate, toDate) - .map(toRun); + async listRunsBetween(standupId: number, fromDate: string, toDate: string): Promise { + const rows = await this.db.all( + 'SELECT * FROM runs WHERE standup_id = ? AND date >= ? AND date <= ? ORDER BY date', + [standupId, fromDate, toDate], + ); + return rows.map(toRun); } - closeRun(id: number): void { - this.db.prepare(`UPDATE runs SET status = 'closed' WHERE id = ?`).run(id); + async listRecentRuns(standupId: number, limit: number): Promise { + const rows = await this.db.all( + 'SELECT * FROM runs WHERE standup_id = ? ORDER BY date DESC LIMIT ?', + [standupId, limit], + ); + return rows.map(toRun); } - listRunParticipants(runId: number): RunParticipant[] { - return this.db - .prepare('SELECT * FROM run_participants WHERE run_id = ? ORDER BY display_name') - .all(runId) - .map(toRunParticipant); + async closeRun(id: number): Promise { + await this.db.run(`UPDATE runs SET status = 'closed' WHERE id = ?`, [id]); + } + + async listRunParticipants(runId: number): Promise { + const rows = await this.db.all( + 'SELECT * FROM run_participants WHERE run_id = ? ORDER BY display_name', + [runId], + ); + return rows.map(toRunParticipant); } - markPrompted(runId: number, userName: string, at: string): void { - this.db - .prepare('UPDATE run_participants SET prompted_at = ? WHERE run_id = ? AND user_name = ?') - .run(at, runId, userName); + async markPrompted(runId: number, userName: string, at: string): Promise { + await this.db.run('UPDATE run_participants SET prompted_at = ? WHERE run_id = ? AND user_name = ?', [ + at, + runId, + userName, + ]); } - markReminded(runId: number, userName: string, at: string): void { - this.db - .prepare('UPDATE run_participants SET reminded_at = ? WHERE run_id = ? AND user_name = ?') - .run(at, runId, userName); + async markReminded(runId: number, userName: string, at: string): Promise { + await this.db.run('UPDATE run_participants SET reminded_at = ? WHERE run_id = ? AND user_name = ?', [ + at, + runId, + userName, + ]); } /** Marks the run-level snapshot only (e.g. calendar OOO for a single day). */ - markRunVacation(runId: number, userName: string): void { - this.db - .prepare('UPDATE run_participants SET on_vacation = 1 WHERE run_id = ? AND user_name = ?') - .run(runId, userName); + async markRunVacation(runId: number, userName: string): Promise { + await this.db.run('UPDATE run_participants SET on_vacation = 1 WHERE run_id = ? AND user_name = ?', [ + runId, + userName, + ]); } - markSkipped(runId: number, userName: string, at: string): boolean { - return ( - this.db - .prepare('UPDATE run_participants SET skipped_at = ? WHERE run_id = ? AND user_name = ?') - .run(at, runId, userName).changes > 0 + async markSkipped(runId: number, userName: string, at: string): Promise { + const result = await this.db.run( + 'UPDATE run_participants SET skipped_at = ? WHERE run_id = ? AND user_name = ?', + [at, runId, userName], ); + return result.changes > 0; } // --- submissions --- - createSubmission(input: { + async createSubmission(input: { runId: number; userName: string; displayName: string; @@ -581,13 +718,11 @@ export class Repo { mood: Mood | null; late: boolean; submittedAt: string; - }): Submission { - const result = this.db - .prepare( - `INSERT INTO submissions (run_id, user_name, display_name, answers, mood, late, submitted_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run( + }): Promise { + const id = await this.db.insert( + `INSERT INTO submissions (run_id, user_name, display_name, answers, mood, late, submitted_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ input.runId, input.userName, input.displayName, @@ -595,174 +730,162 @@ export class Repo { input.mood, input.late ? 1 : 0, input.submittedAt, - ); - return this.getSubmissionById(Number(result.lastInsertRowid))!; + ], + ); + return (await this.getSubmissionById(id))!; } - updateSubmission(id: number, answers: Answer[], mood: Mood | null, editedAt: string): Submission { - this.db - .prepare('UPDATE submissions SET answers = ?, mood = ?, edited_at = ? WHERE id = ?') - .run(JSON.stringify(answers), mood, editedAt, id); - return this.getSubmissionById(id)!; + async updateSubmission(id: number, answers: Answer[], mood: Mood | null, editedAt: string): Promise { + await this.db.run('UPDATE submissions SET answers = ?, mood = ?, edited_at = ? WHERE id = ?', [ + JSON.stringify(answers), + mood, + editedAt, + id, + ]); + return (await this.getSubmissionById(id))!; } - setSubmissionMessageName(id: number, messageName: string): void { - this.db.prepare('UPDATE submissions SET message_name = ? WHERE id = ?').run(messageName, id); + async setSubmissionMessageName(id: number, messageName: string): Promise { + await this.db.run('UPDATE submissions SET message_name = ? WHERE id = ?', [messageName, id]); } - getSubmissionById(id: number): Submission | null { - const row = this.db.prepare('SELECT * FROM submissions WHERE id = ?').get(id); + async getSubmissionById(id: number): Promise { + const row = await this.db.get('SELECT * FROM submissions WHERE id = ?', [id]); return row ? toSubmission(row) : null; } - getSubmission(runId: number, userName: string): Submission | null { - const row = this.db - .prepare('SELECT * FROM submissions WHERE run_id = ? AND user_name = ?') - .get(runId, userName); + async getSubmission(runId: number, userName: string): Promise { + const row = await this.db.get('SELECT * FROM submissions WHERE run_id = ? AND user_name = ?', [ + runId, + userName, + ]); return row ? toSubmission(row) : null; } - listSubmissions(runId: number): Submission[] { - return this.db - .prepare('SELECT * FROM submissions WHERE run_id = ? ORDER BY submitted_at') - .all(runId) - .map(toSubmission); + async listSubmissions(runId: number): Promise { + const rows = await this.db.all('SELECT * FROM submissions WHERE run_id = ? ORDER BY submitted_at', [ + runId, + ]); + return rows.map(toSubmission); } /** The user's most recent submission for this standup, before the given run. */ - getPreviousSubmission(standupId: number, userName: string, beforeRunId: number): Submission | null { - const row = this.db - .prepare( - `SELECT sub.* FROM submissions sub - JOIN runs r ON r.id = sub.run_id - WHERE r.standup_id = ? AND sub.user_name = ? AND sub.run_id != ? - ORDER BY r.date DESC LIMIT 1`, - ) - .get(standupId, userName, beforeRunId); + async getPreviousSubmission( + standupId: number, + userName: string, + beforeRunId: number, + ): Promise { + const row = await this.db.get( + `SELECT sub.* FROM submissions sub + JOIN runs r ON r.id = sub.run_id + WHERE r.standup_id = ? AND sub.user_name = ? AND sub.run_id != ? + ORDER BY r.date DESC LIMIT 1`, + [standupId, userName, beforeRunId], + ); return row ? toSubmission(row) : null; } - listSubmissionsBetween( + async listSubmissionsBetween( standupId: number, fromDate: string, toDate: string, - ): { submission: Submission; runDate: string }[] { - return this.db - .prepare( - `SELECT sub.*, r.date AS run_date FROM submissions sub - JOIN runs r ON r.id = sub.run_id - WHERE r.standup_id = ? AND r.date >= ? AND r.date <= ? - ORDER BY r.date, sub.submitted_at`, - ) - .all(standupId, fromDate, toDate) - .map((row: any) => ({ submission: toSubmission(row), runDate: row.run_date })); + ): Promise<{ submission: Submission; runDate: string }[]> { + const rows = await this.db.all( + `SELECT sub.*, r.date AS run_date FROM submissions sub + JOIN runs r ON r.id = sub.run_id + WHERE r.standup_id = ? AND r.date >= ? AND r.date <= ? + ORDER BY r.date, sub.submitted_at`, + [standupId, fromDate, toDate], + ); + return rows.map((row: any) => ({ submission: toSubmission(row), runDate: row.run_date })); } // --- blockers --- - openBlocker(input: { + async openBlocker(input: { standupId: number; userName: string; displayName: string; text: string; runId: number; date: string; - }): void { - this.db - .prepare( - `INSERT INTO blockers (standup_id, user_name, display_name, text, opened_run_id, opened_date) - VALUES (@standupId, @userName, @displayName, @text, @runId, @date)`, - ) - .run(input); + }): Promise { + await this.db.run( + `INSERT INTO blockers (standup_id, user_name, display_name, text, opened_run_id, opened_date) + VALUES (?, ?, ?, ?, ?, ?)`, + [input.standupId, input.userName, input.displayName, input.text, input.runId, input.date], + ); } /** Used when a submission is edited: re-derive its blockers from scratch. */ - deleteBlockersOpenedBy(runId: number, userName: string): void { - this.db.prepare('DELETE FROM blockers WHERE opened_run_id = ? AND user_name = ?').run(runId, userName); + async deleteBlockersOpenedBy(runId: number, userName: string): Promise { + await this.db.run('DELETE FROM blockers WHERE opened_run_id = ? AND user_name = ?', [runId, userName]); } - resolveBlockersFor(standupId: number, userName: string, runId: number, date: string): number { - return this.db - .prepare( - `UPDATE blockers SET resolved_run_id = ?, resolved_date = ? - WHERE standup_id = ? AND user_name = ? AND resolved_run_id IS NULL AND opened_run_id != ?`, - ) - .run(runId, date, standupId, userName, runId).changes; + async resolveBlockersFor(standupId: number, userName: string, runId: number, date: string): Promise { + const result = await this.db.run( + `UPDATE blockers SET resolved_run_id = ?, resolved_date = ? + WHERE standup_id = ? AND user_name = ? AND resolved_run_id IS NULL AND opened_run_id != ?`, + [runId, date, standupId, userName, runId], + ); + return result.changes; } - listOpenBlockers(standupId: number): Blocker[] { - return this.db - .prepare( - 'SELECT * FROM blockers WHERE standup_id = ? AND resolved_run_id IS NULL ORDER BY opened_date', - ) - .all(standupId) - .map(toBlocker); + async listOpenBlockers(standupId: number): Promise { + const rows = await this.db.all( + 'SELECT * FROM blockers WHERE standup_id = ? AND resolved_run_id IS NULL ORDER BY opened_date', + [standupId], + ); + return rows.map(toBlocker); } - countBlockersOpenedBetween(standupId: number, fromDate: string, toDate: string): number { - return ( - this.db - .prepare( - 'SELECT COUNT(*) AS n FROM blockers WHERE standup_id = ? AND opened_date >= ? AND opened_date <= ?', - ) - .get(standupId, fromDate, toDate) as { n: number } - ).n; + async countBlockersOpenedBetween(standupId: number, fromDate: string, toDate: string): Promise { + const row = await this.db.get( + 'SELECT COUNT(*) AS n FROM blockers WHERE standup_id = ? AND opened_date >= ? AND opened_date <= ?', + [standupId, fromDate, toDate], + ); + return Number(row.n); } - countBlockersResolvedBetween(standupId: number, fromDate: string, toDate: string): number { - return ( - this.db - .prepare( - 'SELECT COUNT(*) AS n FROM blockers WHERE standup_id = ? AND resolved_date >= ? AND resolved_date <= ?', - ) - .get(standupId, fromDate, toDate) as { n: number } - ).n; + async countBlockersResolvedBetween(standupId: number, fromDate: string, toDate: string): Promise { + const row = await this.db.get( + 'SELECT COUNT(*) AS n FROM blockers WHERE standup_id = ? AND resolved_date >= ? AND resolved_date <= ?', + [standupId, fromDate, toDate], + ); + return Number(row.n); } - markBlockerEscalated(id: number, at: string): void { - this.db.prepare('UPDATE blockers SET escalated_at = ? WHERE id = ?').run(at, id); + async markBlockerEscalated(id: number, at: string): Promise { + await this.db.run('UPDATE blockers SET escalated_at = ? WHERE id = ?', [at, id]); } // --- user emails (learned from Chat interaction events) --- - setUserEmail(userName: string, email: string): void { - this.db - .prepare( - `INSERT INTO user_emails (user_name, email) VALUES (?, ?) - ON CONFLICT (user_name) DO UPDATE SET email = excluded.email`, - ) - .run(userName, email); + async setUserEmail(userName: string, email: string): Promise { + await this.db.run( + `INSERT INTO user_emails (user_name, email) VALUES (?, ?) + ON CONFLICT (user_name) DO UPDATE SET email = excluded.email`, + [userName, email], + ); } - getUserEmail(userName: string): string | null { - const row = this.db.prepare('SELECT email FROM user_emails WHERE user_name = ?').get(userName) as - | { email: string } - | undefined; + async getUserEmail(userName: string): Promise { + const row = await this.db.get('SELECT email FROM user_emails WHERE user_name = ?', [userName]); return row?.email ?? null; } - listRecentRuns(standupId: number, limit: number): Run[] { - return this.db - .prepare('SELECT * FROM runs WHERE standup_id = ? ORDER BY date DESC LIMIT ?') - .all(standupId, limit) - .map(toRun); - } - // --- DM space cache (used by the Google Chat adapter) --- - getDmSpace(userName: string): string | null { - const row = this.db.prepare('SELECT space_name FROM dm_spaces WHERE user_name = ?').get(userName) as - | { space_name: string } - | undefined; + async getDmSpace(userName: string): Promise { + const row = await this.db.get('SELECT space_name FROM dm_spaces WHERE user_name = ?', [userName]); return row?.space_name ?? null; } - setDmSpace(userName: string, spaceName: string): void { - this.db - .prepare( - `INSERT INTO dm_spaces (user_name, space_name) VALUES (?, ?) - ON CONFLICT (user_name) DO UPDATE SET space_name = excluded.space_name`, - ) - .run(userName, spaceName); + async setDmSpace(userName: string, spaceName: string): Promise { + await this.db.run( + `INSERT INTO dm_spaces (user_name, space_name) VALUES (?, ?) + ON CONFLICT (user_name) DO UPDATE SET space_name = excluded.space_name`, + [userName, spaceName], + ); } } diff --git a/src/index.ts b/src/index.ts index 43da107..22e791f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,8 +16,15 @@ import { createServer } from './server.js'; const config = loadConfig(); -mkdirSync(dirname(config.dbPath), { recursive: true }); -const repo = new Repo(config.dbPath); +let repo: Repo; +if (config.databaseUrl) { + repo = await Repo.postgres(config.databaseUrl); + console.log('[db] using PostgreSQL (DATABASE_URL)'); +} else { + mkdirSync(dirname(config.dbPath), { recursive: true }); + repo = await Repo.sqlite(config.dbPath); + console.log(`[db] using embedded SQLite at ${config.dbPath}`); +} const adapter = config.adapter === 'google' @@ -56,7 +63,7 @@ if (config.calendarOoo) { } const scheduler = new Scheduler(repo, adapter, service, undefined, undefined, summarizer, oooChecker); -scheduler.start(); +const timer = scheduler.start(); scheduler.tick().catch((err) => console.error('[scheduler] initial tick failed:', err)); const app = createServer({ @@ -69,6 +76,19 @@ const app = createServer({ dashboardToken: config.dashboardToken, }); if (config.dashboardToken) console.log('[dashboard] enabled at /dashboard'); -app.listen(config.port, () => { - console.log(`asyncup listening on :${config.port} (adapter: ${config.adapter}, db: ${config.dbPath})`); +const server = app.listen(config.port, () => { + console.log(`asyncup listening on :${config.port} (adapter: ${config.adapter}, db: ${config.databaseUrl ? 'postgres' : config.dbPath})`); }); + +function shutdown(signal: string): void { + console.log(`[server] received ${signal}, shutting down`); + clearInterval(timer); + server.close(async () => { + await repo.close(); + process.exit(0); + }); + setTimeout(() => process.exit(1), 10_000).unref(); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/server.ts b/src/server.ts index 1386c63..ff0e58f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ import express, { type Express, type Request } from 'express'; +import { rateLimit } from 'express-rate-limit'; import { DateTime } from 'luxon'; import type { EventRouter } from './adapters/gchat/events.js'; import type { ChatRequestVerifier } from './adapters/gchat/auth.js'; @@ -28,12 +29,23 @@ export function createServer(deps: ServerDeps): Express { const { router, verifier, scheduler, repo, tickToken, exportToken } = deps; const now = deps.now ?? (() => DateTime.utc()); const app = express(); + // First reverse-proxy hop is trusted so rate limiting sees real client IPs. + app.set('trust proxy', 1); app.use(express.json()); + // Brute-force protection for every token-checking endpoint. + const authLimiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: 'draft-8', legacyHeaders: false }); + app.use(['/dashboard', '/export', '/tick'], authLimiter); + registerDashboard(app, { repo, token: deps.dashboardToken, now: deps.now }); - app.get('/healthz', (_req, res) => { - res.json({ ok: true }); + app.get('/healthz', async (_req, res) => { + try { + await repo.ping(); + res.json({ ok: true }); + } catch { + res.status(500).json({ ok: false }); + } }); app.post('/chat/events', async (req, res) => { @@ -62,7 +74,7 @@ export function createServer(deps: ServerDeps): Express { // CSV export — disabled unless EXPORT_TOKEN is configured (the data is // your team's standup answers; never expose it unauthenticated). - app.get('/export', (req, res) => { + app.get('/export', async (req, res) => { if (!exportToken) { res.status(404).json({ error: 'export disabled — set EXPORT_TOKEN to enable' }); return; @@ -71,14 +83,14 @@ export function createServer(deps: ServerDeps): Express { res.status(401).json({ error: 'unauthorized' }); return; } - const standup = findStandup(repo, Number(req.query.standupId)); + const standup = await findStandup(repo, Number(req.query.standupId)); if (!standup) { res.status(404).json({ error: 'unknown standupId' }); return; } const days = Math.min(Math.max(Number(req.query.days) || 30, 1), 365); const today = now().setZone(standup.timezone); - const csv = buildCsv(repo, standup, today.minus({ days }).toISODate()!, today.toISODate()!); + const csv = await buildCsv(repo, standup, today.minus({ days }).toISODate()!, today.toISODate()!); res .header('content-type', 'text/csv; charset=utf-8') .header('content-disposition', `attachment; filename="standup-${standup.id}-last-${days}d.csv"`) @@ -88,6 +100,6 @@ export function createServer(deps: ServerDeps): Express { return app; } -function findStandup(repo: Repo, id: number) { +async function findStandup(repo: Repo, id: number) { return Number.isInteger(id) ? repo.getStandupById(id) : null; } diff --git a/tests/commands.test.ts b/tests/commands.test.ts index e0285c9..a385d65 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -12,192 +12,192 @@ function ctx(text: string, mentions: Mention[] = [], sender: Mention = ADMIN) { } describe('CommandHandler', () => { - it('shows help for empty or help text', () => { - const { commands } = makeStack(); - expect(commands.handle(ctx(''))).toContain('AsyncUp commands'); - expect(commands.handle(ctx('help'))).toContain('setup'); + it('shows help for empty or help text', async () => { + const { commands } = await makeStack(); + expect(await commands.handle(ctx(''))).toContain('AsyncUp commands'); + expect(await commands.handle(ctx('help'))).toContain('setup'); }); - it('requires setup before other commands', () => { - const { commands } = makeStack(); - expect(commands.handle(ctx('status'))).toContain('Run `setup` first'); + it('requires setup before other commands', async () => { + const { commands } = await makeStack(); + expect(await commands.handle(ctx('status'))).toContain('Run `setup` first'); }); - it('creates a standup with setup, making the creator admin', () => { - const { commands, repo } = makeStack(); - const reply = commands.handle(ctx('setup Platform Team')); + it('creates a standup with setup, making the creator admin', async () => { + const { commands, repo } = await makeStack(); + const reply = await commands.handle(ctx('setup Platform Team')); expect(reply).toContain('Platform Team'); expect(reply).toContain('You are its admin'); - const standup = repo.listStandupsBySpace(TENANT, SPACE)[0]!; - expect(repo.isAdmin(standup.id, ADMIN.userName)).toBe(true); + const standup = (await repo.listStandupsBySpace(TENANT, SPACE))[0]!; + expect(await repo.isAdmin(standup.id, ADMIN.userName)).toBe(true); }); - it('supports multiple standups per space via #id addressing', () => { - const { commands, repo } = makeStack(); - commands.handle(ctx('setup Eng')); - commands.handle(ctx('setup Design')); - const [eng, design] = repo.listStandupsBySpace(TENANT, SPACE); + it('supports multiple standups per space via #id addressing', async () => { + const { commands, repo } = await makeStack(); + await commands.handle(ctx('setup Eng')); + await commands.handle(ctx('setup Design')); + const [eng, design] = await repo.listStandupsBySpace(TENANT, SPACE); // ambiguous without a prefix - expect(commands.handle(ctx('time 08:00'))).toContain('prefix your command'); + expect(await commands.handle(ctx('time 08:00'))).toContain('prefix your command'); - expect(commands.handle(ctx(`#${design!.id} time 08:00`))).toContain('08:00'); - expect(repo.getStandupById(design!.id)!.promptTime).toBe('08:00'); - expect(repo.getStandupById(eng!.id)!.promptTime).toBe('09:30'); + expect(await commands.handle(ctx(`#${design!.id} time 08:00`))).toContain('08:00'); + expect((await repo.getStandupById(design!.id))!.promptTime).toBe('08:00'); + expect((await repo.getStandupById(eng!.id))!.promptTime).toBe('09:30'); - expect(commands.handle(ctx('#999 time 08:00'))).toContain('No standup #999'); + expect(await commands.handle(ctx('#999 time 08:00'))).toContain('No standup #999'); // bare status shows all standups - const status = commands.handle(ctx('status')); + const status = await commands.handle(ctx('status')); expect(status).toContain('Eng'); expect(status).toContain('Design'); }); - it('restricts config commands to admins', () => { - const { commands, repo } = makeStack(); - commands.handle(ctx('setup')); - const standup = repo.listStandupsBySpace(TENANT, SPACE)[0]!; + it('restricts config commands to admins', async () => { + const { commands, repo } = await makeStack(); + await commands.handle(ctx('setup')); + const standup = (await repo.listStandupsBySpace(TENANT, SPACE))[0]!; - expect(commands.handle(ctx('time 08:00', [], ALICE))).toContain('🔒 Only admins'); - expect(commands.handle(ctx('status', [], ALICE))).not.toContain('🔒'); + expect(await commands.handle(ctx('time 08:00', [], ALICE))).toContain('🔒 Only admins'); + expect(await commands.handle(ctx('status', [], ALICE))).not.toContain('🔒'); - commands.handle(ctx('admin @Alice', [ALICE])); - expect(repo.isAdmin(standup.id, ALICE.userName)).toBe(true); - expect(commands.handle(ctx('time 08:00', [], ALICE))).toContain('08:00'); + await commands.handle(ctx('admin @Alice', [ALICE])); + expect(await repo.isAdmin(standup.id, ALICE.userName)).toBe(true); + expect(await commands.handle(ctx('time 08:00', [], ALICE))).toContain('08:00'); // can't remove the last admin - commands.handle(ctx('unadmin @Admin', [ADMIN], ALICE)); - expect(commands.handle(ctx('unadmin @Alice', [ALICE], ALICE))).toContain('at least one admin'); + await commands.handle(ctx('unadmin @Admin', [ADMIN], ALICE)); + expect(await commands.handle(ctx('unadmin @Alice', [ALICE], ALICE))).toContain('at least one admin'); }); - it('adds, removes and toggles participants via mentions', () => { - const { commands, repo } = makeStack(); - commands.handle(ctx('setup')); - expect(commands.handle(ctx('add'))).toContain('Mention the people'); + it('adds, removes and toggles participants via mentions', async () => { + const { commands, repo } = await makeStack(); + await commands.handle(ctx('setup')); + expect(await commands.handle(ctx('add'))).toContain('Mention the people'); - commands.handle(ctx('add @Alice @Bob', [ALICE, BOB])); - const standup = repo.listStandupsBySpace(TENANT, SPACE)[0]!; - expect(repo.listParticipants(standup.id)).toHaveLength(2); - expect(repo.listParticipants(standup.id).every((p) => p.mandatory)).toBe(true); + await commands.handle(ctx('add @Alice @Bob', [ALICE, BOB])); + const standup = (await repo.listStandupsBySpace(TENANT, SPACE))[0]!; + expect(await repo.listParticipants(standup.id)).toHaveLength(2); + expect((await repo.listParticipants(standup.id)).every((p) => p.mandatory)).toBe(true); - expect(commands.handle(ctx('optional @Bob', [BOB]))).toContain('Bob now optional'); - expect(commands.handle(ctx('mandatory @Bob', [BOB]))).toContain('Bob now mandatory'); - expect(commands.handle(ctx('remove @Alice', [ALICE]))).toContain('Removed Alice'); - expect(repo.listParticipants(standup.id)).toHaveLength(1); + expect(await commands.handle(ctx('optional @Bob', [BOB]))).toContain('Bob now optional'); + expect(await commands.handle(ctx('mandatory @Bob', [BOB]))).toContain('Bob now mandatory'); + expect(await commands.handle(ctx('remove @Alice', [ALICE]))).toContain('Removed Alice'); + expect(await repo.listParticipants(standup.id)).toHaveLength(1); }); - it('marks people on vacation and back', () => { - const { commands, repo } = makeStack(); - commands.handle(ctx('setup')); - commands.handle(ctx('add @Alice', [ALICE])); - const standup = repo.listStandupsBySpace(TENANT, SPACE)[0]!; - - expect(commands.handle(ctx('vacation @Alice', [ALICE]))).toContain('🏖️ Alice'); - expect(repo.listParticipants(standup.id)[0]!.onVacation).toBe(true); - expect(commands.handle(ctx('back @Alice', [ALICE]))).toContain('Alice back'); - expect(repo.listParticipants(standup.id)[0]!.onVacation).toBe(false); - expect(commands.handle(ctx('vacation @Bob', [BOB]))).toContain('Not participants'); + it('marks people on vacation and back', async () => { + const { commands, repo } = await makeStack(); + await commands.handle(ctx('setup')); + await commands.handle(ctx('add @Alice', [ALICE])); + const standup = (await repo.listStandupsBySpace(TENANT, SPACE))[0]!; + + expect(await commands.handle(ctx('vacation @Alice', [ALICE]))).toContain('🏖️ Alice'); + expect((await repo.listParticipants(standup.id))[0]!.onVacation).toBe(true); + expect(await commands.handle(ctx('back @Alice', [ALICE]))).toContain('Alice back'); + expect((await repo.listParticipants(standup.id))[0]!.onVacation).toBe(false); + expect(await commands.handle(ctx('vacation @Bob', [BOB]))).toContain('Not participants'); }); - it('validates and sets times', () => { - const { commands, repo } = makeStack(); - commands.handle(ctx('setup')); - expect(commands.handle(ctx('time 25:00'))).toContain('24h time'); - expect(commands.handle(ctx('time 08:30'))).toContain('08:30'); - expect(commands.handle(ctx('deadline 10:00'))).toContain('10:00'); - expect(commands.handle(ctx('deadline 08:00'))).toContain('must be before'); - const standup = repo.listStandupsBySpace(TENANT, SPACE)[0]!; + it('validates and sets times', async () => { + const { commands, repo } = await makeStack(); + await commands.handle(ctx('setup')); + expect(await commands.handle(ctx('time 25:00'))).toContain('24h time'); + expect(await commands.handle(ctx('time 08:30'))).toContain('08:30'); + expect(await commands.handle(ctx('deadline 10:00'))).toContain('10:00'); + expect(await commands.handle(ctx('deadline 08:00'))).toContain('must be before'); + const standup = (await repo.listStandupsBySpace(TENANT, SPACE))[0]!; expect(standup.promptTime).toBe('08:30'); expect(standup.deadlineTime).toBe('10:00'); }); - it('validates timezone, days and reminder', () => { - const { commands, repo } = makeStack(); - commands.handle(ctx('setup')); - expect(commands.handle(ctx('timezone Mars/Olympus'))).toContain('valid IANA'); - expect(commands.handle(ctx('timezone Europe/Berlin'))).toContain('Europe/Berlin'); - expect(commands.handle(ctx('days mon,funday'))).toContain('list days'); - expect(commands.handle(ctx('days wed,mon'))).toContain('mon, wed'); - expect(commands.handle(ctx('remind 0'))).toContain('disabled'); - const standup = repo.listStandupsBySpace(TENANT, SPACE)[0]!; + it('validates timezone, days and reminder', async () => { + const { commands, repo } = await makeStack(); + await commands.handle(ctx('setup')); + expect(await commands.handle(ctx('timezone Mars/Olympus'))).toContain('valid IANA'); + expect(await commands.handle(ctx('timezone Europe/Berlin'))).toContain('Europe/Berlin'); + expect(await commands.handle(ctx('days mon,funday'))).toContain('list days'); + expect(await commands.handle(ctx('days wed,mon'))).toContain('mon, wed'); + expect(await commands.handle(ctx('remind 0'))).toContain('disabled'); + const standup = (await repo.listStandupsBySpace(TENANT, SPACE))[0]!; expect(standup.timezone).toBe('Europe/Berlin'); expect(standup.days).toBe('mon,wed'); }); - it('manages custom questions', () => { - const { commands, repo } = makeStack(); - commands.handle(ctx('setup')); - expect(commands.handle(ctx('questions'))).toContain('What did you do yesterday?'); + it('manages custom questions', async () => { + const { commands, repo } = await makeStack(); + await commands.handle(ctx('setup')); + expect(await commands.handle(ctx('questions'))).toContain('What did you do yesterday?'); - const reply = commands.handle(ctx('questions set What shipped? | What is next? | Any blockers?')); + const reply = await commands.handle(ctx('questions set What shipped? | What is next? | Any blockers?')); expect(reply).toContain('1. What shipped?'); - const standup = repo.listStandupsBySpace(TENANT, SPACE)[0]!; + const standup = (await repo.listStandupsBySpace(TENANT, SPACE))[0]!; expect(standup.questions).toEqual(['What shipped?', 'What is next?', 'Any blockers?']); - expect(commands.handle(ctx('questions set'))).toContain('separated by `|`'); - expect(commands.handle(ctx('questions reset'))).toContain('defaults'); - expect(repo.listStandupsBySpace(TENANT, SPACE)[0]!.questions).toBeNull(); + expect(await commands.handle(ctx('questions set'))).toContain('separated by `|`'); + expect(await commands.handle(ctx('questions reset'))).toContain('defaults'); + expect((await repo.listStandupsBySpace(TENANT, SPACE))[0]!.questions).toBeNull(); }); - it('toggles mood, digest and ai', () => { - const { commands, repo } = makeStack(); - commands.handle(ctx('setup')); - expect(commands.handle(ctx('mood off'))).toContain('Mood question off'); - expect(commands.handle(ctx('digest on'))).toContain('Weekly digest on'); - expect(commands.handle(ctx('ai on'))).toContain('LLM_PROVIDER'); - expect(commands.handle(ctx('ai banana'))).toContain('`on` or `off`'); - const standup = repo.listStandupsBySpace(TENANT, SPACE)[0]!; + it('toggles mood, digest and ai', async () => { + const { commands, repo } = await makeStack(); + await commands.handle(ctx('setup')); + expect(await commands.handle(ctx('mood off'))).toContain('Mood question off'); + expect(await commands.handle(ctx('digest on'))).toContain('Weekly digest on'); + expect(await commands.handle(ctx('ai on'))).toContain('LLM_PROVIDER'); + expect(await commands.handle(ctx('ai banana'))).toContain('`on` or `off`'); + const standup = (await repo.listStandupsBySpace(TENANT, SPACE))[0]!; expect(standup.moodEnabled).toBe(false); expect(standup.digestEnabled).toBe(true); expect(standup.aiEnabled).toBe(true); }); it('lists open blockers with age', async () => { - const { commands, repo, service, clock } = makeStack(); - commands.handle(ctx('setup')); - commands.handle(ctx('add @Alice', [ALICE])); - const standup = repo.listStandupsBySpace(TENANT, SPACE)[0]!; - expect(commands.handle(ctx('blockers'))).toContain('No open blockers'); + const { commands, repo, service, clock } = await makeStack(); + await commands.handle(ctx('setup')); + await commands.handle(ctx('add @Alice', [ALICE])); + const standup = (await repo.listStandupsBySpace(TENANT, SPACE))[0]!; + expect(await commands.handle(ctx('blockers'))).toContain('No open blockers'); - const run = repo.createRun(standup.id, '2026-06-08', 'k'); + const run = await repo.createRun(standup.id, '2026-06-08', 'k'); await service.submit(run.id, ALICE.userName, ALICE.displayName, withBlocker('Waiting on keys')); clock.set('2026-06-10T12:00'); - const reply = commands.handle(ctx('blockers')); + const reply = await commands.handle(ctx('blockers')); expect(reply).toContain('Waiting on keys'); expect(reply).toContain('(2d old)'); }); - it('shows trends and export info', () => { - const { commands } = makeStack(); - commands.handle(ctx('setup')); - expect(commands.handle(ctx('trends'))).toContain('last 4 weeks'); - const exportReply = commands.handle(ctx('export')); + it('shows trends and export info', async () => { + const { commands } = await makeStack(); + await commands.handle(ctx('setup')); + expect(await commands.handle(ctx('trends'))).toContain('last 4 weeks'); + const exportReply = await commands.handle(ctx('export')); expect(exportReply).toContain('/export?standupId='); expect(exportReply).toContain('EXPORT_TOKEN'); }); it('reports status including today’s progress with away handling', async () => { - const { commands, repo, scheduler, service, clock } = makeStack(); - commands.handle(ctx('setup')); - commands.handle(ctx('add @Alice @Bob', [ALICE, BOB])); + const { commands, repo, scheduler, service, clock } = await makeStack(); + await commands.handle(ctx('setup')); + await commands.handle(ctx('add @Alice @Bob', [ALICE, BOB])); clock.set('2026-06-10T09:30'); await scheduler.tick(); - const standup = repo.listStandupsBySpace(TENANT, SPACE)[0]!; - const run = repo.getRun(standup.id, '2026-06-10')!; + const standup = (await repo.listStandupsBySpace(TENANT, SPACE))[0]!; + const run = (await repo.getRun(standup.id, '2026-06-10'))!; await service.submit(run.id, ALICE.userName, ALICE.displayName, ANSWERS); - service.skipToday(run.id, BOB.userName); + await service.skipToday(run.id, BOB.userName); - const status = commands.handle(ctx('status')); + const status = await commands.handle(ctx('status')); expect(status).toContain('1/1 submitted'); expect(status).toContain('✅ Alice'); expect(status).toContain('🏖️ Bob'); expect(status).toContain('Admins: Admin'); }); - it('rejects unknown commands', () => { - const { commands } = makeStack(); - commands.handle(ctx('setup')); - expect(commands.handle(ctx('frobnicate'))).toContain('Unknown command'); + it('rejects unknown commands', async () => { + const { commands } = await makeStack(); + await commands.handle(ctx('setup')); + expect(await commands.handle(ctx('frobnicate'))).toContain('Unknown command'); }); }); diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 8bf2c12..9d90a06 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -6,8 +6,8 @@ import { ANSWERS, makeStack, seedStandup, TENANT } from './helpers.js'; let close: (() => void) | null = null; -function startServer(dashboardToken = 'dash-secret') { - const stack = makeStack(); +async function startServer(dashboardToken = 'dash-secret') { + const stack = await makeStack(); const router = new EventRouter(stack.commands, stack.service, stack.repo, TENANT); const app = createServer({ router, @@ -34,12 +34,12 @@ afterEach(() => { describe('dashboard', () => { it('is disabled entirely without a token', async () => { - const { url } = startServer(''); + const { url } = await startServer(''); expect((await fetch(`${url}/dashboard`)).status).toBe(404); }); it('rejects missing/wrong credentials and accepts the token via query or cookie', async () => { - const { url, get } = startServer(); + const { url, get } = await startServer(); expect((await get('/dashboard', false)).status).toBe(401); expect( (await fetch(`${url}/dashboard`, { headers: { cookie: 'asyncup_dash=wrong' } })).status, @@ -53,9 +53,9 @@ describe('dashboard', () => { }); it('lists standups and shows the detail page with history and blockers', async () => { - const { repo, service, get, clock } = startServer(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'k'); + const { repo, service, get, clock } = await startServer(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'k'); await service.submit(run.id, 'users/alice', 'Alice', { ...ANSWERS, answers: [...ANSWERS.answers.slice(0, 2), { question: 'Any blockers?', answer: 'Stuck on VPN' }], @@ -78,8 +78,8 @@ describe('dashboard', () => { }); it('updates configuration via the form and validates input', async () => { - const { repo, url, get } = startServer(); - const standup = seedStandup(repo); + const { repo, url } = await startServer(); + const standup = await seedStandup(repo); const post = (body: Record) => fetch(`${url}/dashboard/standup/${standup.id}`, { method: 'POST', @@ -106,7 +106,7 @@ describe('dashboard', () => { const ok = await post(valid); expect(ok.status).toBe(302); - const updated = repo.getStandupById(standup.id)!; + const updated = (await repo.getStandupById(standup.id))!; expect(updated.name).toBe('Renamed Standup'); expect(updated.promptTime).toBe('08:15'); expect(updated.timezone).toBe('Europe/Berlin'); @@ -119,7 +119,7 @@ describe('dashboard', () => { const bad = await post({ ...valid, promptTime: '25:99' }); expect(bad.status).toBe(400); expect(await bad.text()).toContain('HH:MM'); - expect(repo.getStandupById(standup.id)!.promptTime).toBe('08:15'); + expect((await repo.getStandupById(standup.id))!.promptTime).toBe('08:15'); // config write requires auth const unauthed = await fetch(`${url}/dashboard/standup/${standup.id}`, { diff --git a/tests/events.test.ts b/tests/events.test.ts index 0fedf4f..c7ae68b 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'; import { EventRouter } from '../src/adapters/gchat/events.js'; import { ANSWERS, makeStack, seedStandup, TENANT } from './helpers.js'; -function makeRouter() { - const stack = makeStack(); +async function makeRouter() { + const stack = await makeStack(); const router = new EventRouter(stack.commands, stack.service, stack.repo, TENANT); return { ...stack, router }; } @@ -32,7 +32,7 @@ const FULL_FORM = { q0: 'Did X', q1: 'Will do Y', q2: 'none', mood: 'good' }; describe('EventRouter', () => { it('routes space messages to the command handler with sender and mentions', async () => { - const { router, repo } = makeRouter(); + const { router, repo } = await makeRouter(); const reply: any = await router.handle({ type: 'MESSAGE', space: { name: 'spaces/team', type: 'ROOM' }, @@ -40,12 +40,12 @@ describe('EventRouter', () => { user: SENDER, }); expect(reply.text).toContain('Platform'); - const standup = repo.listStandupsBySpace(TENANT, 'spaces/team')[0]!; - expect(repo.isAdmin(standup.id, 'users/admin')).toBe(true); + const standup = (await repo.listStandupsBySpace(TENANT, 'spaces/team'))[0]!; + expect(await repo.isAdmin(standup.id, 'users/admin')).toBe(true); }); it('extracts human mentions and ignores the bot', async () => { - const { router, repo } = makeRouter(); + const { router, repo } = await makeRouter(); await router.handle({ type: 'MESSAGE', space: { name: 'spaces/team', type: 'ROOM' }, @@ -71,13 +71,13 @@ describe('EventRouter', () => { user: SENDER, }); expect(reply.text).toContain('Added Alice'); - const standup = repo.listStandupsBySpace(TENANT, 'spaces/team')[0]!; - expect(repo.listParticipants(standup.id).map((p) => p.userName)).toEqual(['users/alice']); + const standup = (await repo.listStandupsBySpace(TENANT, 'spaces/team'))[0]!; + expect((await repo.listParticipants(standup.id)).map((p) => p.userName)).toEqual(['users/alice']); }); it('handles DM self-service vacation and back', async () => { - const { router, repo } = makeRouter(); - const standup = seedStandup(repo); + const { router, repo } = await makeRouter(); + const standup = await seedStandup(repo); const dm = (text: string) => ({ type: 'MESSAGE', space: { name: 'spaces/dm', spaceType: 'DIRECT_MESSAGE' }, @@ -87,9 +87,9 @@ describe('EventRouter', () => { const on: any = await router.handle(dm('vacation')); expect(on.text).toContain('Vacation mode ON'); - expect(repo.listParticipants(standup.id).find((p) => p.userName === 'users/alice')?.onVacation).toBe( - true, - ); + expect( + (await repo.listParticipants(standup.id)).find((p) => p.userName === 'users/alice')?.onVacation, + ).toBe(true); const off: any = await router.handle(dm('back')); expect(off.text).toContain('Welcome back'); @@ -99,11 +99,11 @@ describe('EventRouter', () => { }); it('opens a prefilled dialog from the prompt card', async () => { - const { router, repo, service } = makeRouter(); - const standup = seedStandup(repo); - const run1 = repo.createRun(standup.id, '2026-06-09', 'k1'); + const { router, repo, service } = await makeRouter(); + const standup = await seedStandup(repo); + const run1 = await repo.createRun(standup.id, '2026-06-09', 'k1'); await service.submit(run1.id, 'users/alice', 'Alice', ANSWERS); - const run2 = repo.createRun(standup.id, '2026-06-10', 'k2'); + const run2 = await repo.createRun(standup.id, '2026-06-10', 'k2'); const reply: any = await router.handle({ type: 'CARD_CLICKED', @@ -116,33 +116,33 @@ describe('EventRouter', () => { }); it('records a dialog submission and posts it to the thread', async () => { - const { router, repo, adapter } = makeRouter(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + const { router, repo, adapter } = await makeRouter(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); const reply: any = await router.handle(dialogSubmitEvent(run.id, FULL_FORM)); expect(reply.actionResponse.dialogAction.actionStatus.statusCode).toBe('OK'); - const sub = repo.getSubmission(run.id, 'users/alice')!; + const sub = (await repo.getSubmission(run.id, 'users/alice'))!; expect(sub.answers[1]!.answer).toBe('Will do Y'); expect(adapter.posts.filter((p) => p.kind === 'submission')).toHaveLength(1); }); it('edits via resubmission with a friendly confirmation', async () => { - const { router, repo, adapter } = makeRouter(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + const { router, repo, adapter } = await makeRouter(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); await router.handle(dialogSubmitEvent(run.id, FULL_FORM)); const edit: any = await router.handle(dialogSubmitEvent(run.id, { ...FULL_FORM, q1: 'Changed plan' })); expect(edit.actionResponse.dialogAction.actionStatus.userFacingMessage).toContain('Updated'); - expect(repo.getSubmission(run.id, 'users/alice')!.answers[1]!.answer).toBe('Changed plan'); + expect((await repo.getSubmission(run.id, 'users/alice'))!.answers[1]!.answer).toBe('Changed plan'); expect(adapter.posts.filter((p) => p.kind === 'update')).toHaveLength(1); }); it('validates required answers and mood', async () => { - const { router, repo } = makeRouter(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + const { router, repo } = await makeRouter(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); const missing: any = await router.handle(dialogSubmitEvent(run.id, { q0: 'x', mood: 'good' })); expect(missing.actionResponse.dialogAction.actionStatus.statusCode).toBe('INVALID_ARGUMENT'); @@ -155,24 +155,24 @@ describe('EventRouter', () => { // blockers (q2) may be empty; defaults to "none" const ok: any = await router.handle(dialogSubmitEvent(run.id, { q0: 'x', q1: 'y', mood: 'good' })); expect(ok.actionResponse.dialogAction.actionStatus.statusCode).toBe('OK'); - expect(repo.getSubmission(run.id, 'users/alice')!.answers[2]!.answer).toBe('none'); + expect((await repo.getSubmission(run.id, 'users/alice'))!.answers[2]!.answer).toBe('none'); }); it('skips mood validation when the mood question is disabled', async () => { - const { router, repo } = makeRouter(); - const standup = seedStandup(repo); - repo.updateStandup(standup.id, { moodEnabled: false }); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + const { router, repo } = await makeRouter(); + const standup = await seedStandup(repo); + await repo.updateStandup(standup.id, { moodEnabled: false }); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); const reply: any = await router.handle(dialogSubmitEvent(run.id, { q0: 'x', q1: 'y' })); expect(reply.actionResponse.dialogAction.actionStatus.statusCode).toBe('OK'); - expect(repo.getSubmission(run.id, 'users/alice')!.mood).toBeNull(); + expect((await repo.getSubmission(run.id, 'users/alice'))!.mood).toBeNull(); }); it('handles the skip button with a card update', async () => { - const { router, repo } = makeRouter(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + const { router, repo } = await makeRouter(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); const reply: any = await router.handle({ type: 'CARD_CLICKED', @@ -181,11 +181,13 @@ describe('EventRouter', () => { }); expect(reply.actionResponse.type).toBe('UPDATE_MESSAGE'); expect(reply.text).toContain('Skipped'); - expect(repo.listRunParticipants(run.id).find((p) => p.userName === 'users/alice')?.skippedAt).not.toBeNull(); + expect( + (await repo.listRunParticipants(run.id)).find((p) => p.userName === 'users/alice')?.skippedAt, + ).not.toBeNull(); }); it('welcomes when added to a space', async () => { - const { router } = makeRouter(); + const { router } = await makeRouter(); const reply: any = await router.handle({ type: 'ADDED_TO_SPACE', space: { name: 'spaces/team', type: 'ROOM' }, diff --git a/tests/helpers.ts b/tests/helpers.ts index 562fa67..6ee8284 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -10,8 +10,16 @@ import { Repo } from '../src/db/repo.js'; export const TZ = 'Asia/Kolkata'; export const TENANT = 'default'; -export function makeStack(opts: { summarizer?: AiSummarizer | null } = {}) { - const repo = new Repo(':memory:'); +let schemaCounter = 0; + +export async function makeStack(opts: { summarizer?: AiSummarizer | null } = {}) { + let repo: Repo; + if (process.env.TEST_DATABASE_URL) { + const schema = `t_${process.pid}_${Date.now()}_${schemaCounter++}`; + repo = await Repo.postgres(process.env.TEST_DATABASE_URL, schema); + } else { + repo = await Repo.sqlite(':memory:'); + } const adapter = new FakeAdapter(); let current = DateTime.fromISO('2026-06-10T00:00:00', { zone: TZ }); @@ -30,24 +38,24 @@ export function makeStack(opts: { summarizer?: AiSummarizer | null } = {}) { return { repo, adapter, service, scheduler, commands, clock }; } -export function seedStandup(repo: Repo, opts: { deadlineTime?: string; spaceName?: string } = {}) { - const standup = repo.createStandup({ +export async function seedStandup(repo: Repo, opts: { deadlineTime?: string; spaceName?: string } = {}) { + const standup = await repo.createStandup({ tenantId: TENANT, spaceName: opts.spaceName ?? 'spaces/team', name: 'Daily Standup', timezone: TZ, }); // defaults: prompt 09:30, deadline 11:30, reminder 60m, mon-fri - if (opts.deadlineTime) repo.updateStandup(standup.id, { deadlineTime: opts.deadlineTime }); - repo.upsertParticipant({ standupId: standup.id, userName: 'users/alice', displayName: 'Alice' }); - repo.upsertParticipant({ standupId: standup.id, userName: 'users/bob', displayName: 'Bob' }); - repo.upsertParticipant({ + if (opts.deadlineTime) await repo.updateStandup(standup.id, { deadlineTime: opts.deadlineTime }); + await repo.upsertParticipant({ standupId: standup.id, userName: 'users/alice', displayName: 'Alice' }); + await repo.upsertParticipant({ standupId: standup.id, userName: 'users/bob', displayName: 'Bob' }); + await repo.upsertParticipant({ standupId: standup.id, userName: 'users/carol', displayName: 'Carol', mandatory: false, }); - return repo.getStandupById(standup.id)!; + return (await repo.getStandupById(standup.id))!; } export const ANSWERS: SubmissionInput = { diff --git a/tests/repo.test.ts b/tests/repo.test.ts index 894e0d0..b8abaf9 100644 --- a/tests/repo.test.ts +++ b/tests/repo.test.ts @@ -7,83 +7,83 @@ import { Repo } from '../src/db/repo.js'; import { makeStack, seedStandup, ANSWERS, TENANT } from './helpers.js'; describe('Repo', () => { - it('creates and lists standups by space — several per space allowed', () => { - const { repo } = makeStack(); - const first = seedStandup(repo); - const second = repo.createStandup({ + it('creates and lists standups by space — several per space allowed', async () => { + const { repo } = await makeStack(); + const first = await seedStandup(repo); + const second = await repo.createStandup({ tenantId: TENANT, spaceName: 'spaces/team', name: 'Design Standup', timezone: 'UTC', }); - const list = repo.listStandupsBySpace(TENANT, 'spaces/team'); + const list = await repo.listStandupsBySpace(TENANT, 'spaces/team'); expect(list.map((s) => s.id)).toEqual([first.id, second.id]); - expect(repo.listStandupsBySpace('other-tenant', 'spaces/team')).toEqual([]); + expect(await repo.listStandupsBySpace('other-tenant', 'spaces/team')).toEqual([]); expect(first.questions).toBeNull(); expect(first.moodEnabled).toBe(true); expect(first.digestEnabled).toBe(false); }); - it('updates standup fields selectively, including questions JSON', () => { - const { repo } = makeStack(); - const standup = seedStandup(repo); - repo.updateStandup(standup.id, { + it('updates standup fields selectively, including questions JSON', async () => { + const { repo } = await makeStack(); + const standup = await seedStandup(repo); + await repo.updateStandup(standup.id, { promptTime: '08:00', questions: ['What shipped?', 'Blockers?'], aiEnabled: true, }); - const updated = repo.getStandupById(standup.id)!; + const updated = (await repo.getStandupById(standup.id))!; expect(updated.promptTime).toBe('08:00'); expect(updated.questions).toEqual(['What shipped?', 'Blockers?']); expect(updated.aiEnabled).toBe(true); - repo.updateStandup(standup.id, { questions: null }); - expect(repo.getStandupById(standup.id)!.questions).toBeNull(); + await repo.updateStandup(standup.id, { questions: null }); + expect((await repo.getStandupById(standup.id))!.questions).toBeNull(); }); - it('manages participants: mandatory, vacation, soft remove', () => { - const { repo } = makeStack(); - const standup = seedStandup(repo); - expect(repo.listParticipants(standup.id)).toHaveLength(3); + it('manages participants: mandatory, vacation, soft remove', async () => { + const { repo } = await makeStack(); + const standup = await seedStandup(repo); + expect(await repo.listParticipants(standup.id)).toHaveLength(3); - expect(repo.setParticipantVacation(standup.id, 'users/alice', true)).toBe(true); - expect(repo.listParticipants(standup.id).find((p) => p.userName === 'users/alice')?.onVacation).toBe( - true, - ); - expect(repo.setVacationForUser('users/alice', false)).toBe(1); + expect(await repo.setParticipantVacation(standup.id, 'users/alice', true)).toBe(true); + expect( + (await repo.listParticipants(standup.id)).find((p) => p.userName === 'users/alice')?.onVacation, + ).toBe(true); + expect(await repo.setVacationForUser('users/alice', false)).toBe(1); - expect(repo.removeParticipant(standup.id, 'users/bob')).toBe(true); - expect(repo.listParticipants(standup.id)).toHaveLength(2); + expect(await repo.removeParticipant(standup.id, 'users/bob')).toBe(true); + expect(await repo.listParticipants(standup.id)).toHaveLength(2); }); - it('manages admins', () => { - const { repo } = makeStack(); - const standup = seedStandup(repo); - expect(repo.listAdmins(standup.id)).toEqual([]); - repo.addAdmin(standup.id, 'users/ashish', 'Ashish'); - expect(repo.isAdmin(standup.id, 'users/ashish')).toBe(true); - expect(repo.isAdmin(standup.id, 'users/alice')).toBe(false); - expect(repo.removeAdmin(standup.id, 'users/ashish')).toBe(true); - expect(repo.listAdmins(standup.id)).toEqual([]); + it('manages admins', async () => { + const { repo } = await makeStack(); + const standup = await seedStandup(repo); + expect(await repo.listAdmins(standup.id)).toEqual([]); + await repo.addAdmin(standup.id, 'users/ashish', 'Ashish'); + expect(await repo.isAdmin(standup.id, 'users/ashish')).toBe(true); + expect(await repo.isAdmin(standup.id, 'users/alice')).toBe(false); + expect(await repo.removeAdmin(standup.id, 'users/ashish')).toBe(true); + expect(await repo.listAdmins(standup.id)).toEqual([]); }); - it('snapshots the roster (incl. vacation) when a run is created', () => { - const { repo } = makeStack(); - const standup = seedStandup(repo); - repo.setParticipantVacation(standup.id, 'users/carol', true); - const run = repo.createRun(standup.id, '2026-06-10', 'k'); - const roster = repo.listRunParticipants(run.id); + it('snapshots the roster (incl. vacation) when a run is created', async () => { + const { repo } = await makeStack(); + const standup = await seedStandup(repo); + await repo.setParticipantVacation(standup.id, 'users/carol', true); + const run = await repo.createRun(standup.id, '2026-06-10', 'k'); + const roster = await repo.listRunParticipants(run.id); expect(roster).toHaveLength(3); expect(roster.find((p) => p.userName === 'users/carol')?.onVacation).toBe(true); - repo.removeParticipant(standup.id, 'users/alice'); - expect(repo.listRunParticipants(run.id)).toHaveLength(3); + await repo.removeParticipant(standup.id, 'users/alice'); + expect(await repo.listRunParticipants(run.id)).toHaveLength(3); }); - it('stores submissions with answers JSON, supports edits and message names', () => { - const { repo } = makeStack(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'k'); - const sub = repo.createSubmission({ + it('stores submissions with answers JSON, supports edits and message names', async () => { + const { repo } = await makeStack(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'k'); + const sub = await repo.createSubmission({ runId: run.id, userName: 'users/alice', displayName: 'Alice', @@ -95,8 +95,8 @@ describe('Repo', () => { expect(sub.answers).toHaveLength(3); expect(sub.editedAt).toBeNull(); - repo.setSubmissionMessageName(sub.id, 'messages/abc'); - const edited = repo.updateSubmission( + await repo.setSubmissionMessageName(sub.id, 'messages/abc'); + const edited = await repo.updateSubmission( sub.id, [{ question: 'What did you do yesterday?', answer: 'Changed my mind' }], 'great', @@ -107,7 +107,7 @@ describe('Repo', () => { expect(edited.editedAt).toBe('2026-06-10T10:30:00Z'); expect(edited.messageName).toBe('messages/abc'); - expect(() => + await expect( repo.createSubmission({ runId: run.id, userName: 'users/alice', @@ -117,15 +117,15 @@ describe('Repo', () => { late: false, submittedAt: '2026-06-10T11:00:00Z', }), - ).toThrow(); + ).rejects.toThrow(); }); - it('finds the previous submission for prefill', () => { - const { repo } = makeStack(); - const standup = seedStandup(repo); - const run1 = repo.createRun(standup.id, '2026-06-09', 'k1'); - const run2 = repo.createRun(standup.id, '2026-06-10', 'k2'); - repo.createSubmission({ + it('finds the previous submission for prefill', async () => { + const { repo } = await makeStack(); + const standup = await seedStandup(repo); + const run1 = await repo.createRun(standup.id, '2026-06-09', 'k1'); + const run2 = await repo.createRun(standup.id, '2026-06-10', 'k2'); + await repo.createSubmission({ runId: run1.id, userName: 'users/alice', displayName: 'Alice', @@ -134,17 +134,17 @@ describe('Repo', () => { late: false, submittedAt: '2026-06-09T10:00:00Z', }); - expect(repo.getPreviousSubmission(standup.id, 'users/alice', run2.id)?.runId).toBe(run1.id); - expect(repo.getPreviousSubmission(standup.id, 'users/bob', run2.id)).toBeNull(); + expect((await repo.getPreviousSubmission(standup.id, 'users/alice', run2.id))?.runId).toBe(run1.id); + expect(await repo.getPreviousSubmission(standup.id, 'users/bob', run2.id)).toBeNull(); }); - it('tracks blocker lifecycle', () => { - const { repo } = makeStack(); - const standup = seedStandup(repo); - const run1 = repo.createRun(standup.id, '2026-06-09', 'k1'); - const run2 = repo.createRun(standup.id, '2026-06-10', 'k2'); + it('tracks blocker lifecycle', async () => { + const { repo } = await makeStack(); + const standup = await seedStandup(repo); + const run1 = await repo.createRun(standup.id, '2026-06-09', 'k1'); + const run2 = await repo.createRun(standup.id, '2026-06-10', 'k2'); - repo.openBlocker({ + await repo.openBlocker({ standupId: standup.id, userName: 'users/alice', displayName: 'Alice', @@ -152,15 +152,15 @@ describe('Repo', () => { runId: run1.id, date: '2026-06-09', }); - expect(repo.listOpenBlockers(standup.id)).toHaveLength(1); - expect(repo.countBlockersOpenedBetween(standup.id, '2026-06-08', '2026-06-14')).toBe(1); + expect(await repo.listOpenBlockers(standup.id)).toHaveLength(1); + expect(await repo.countBlockersOpenedBetween(standup.id, '2026-06-08', '2026-06-14')).toBe(1); - expect(repo.resolveBlockersFor(standup.id, 'users/alice', run2.id, '2026-06-10')).toBe(1); - expect(repo.listOpenBlockers(standup.id)).toHaveLength(0); - expect(repo.countBlockersResolvedBetween(standup.id, '2026-06-08', '2026-06-14')).toBe(1); + expect(await repo.resolveBlockersFor(standup.id, 'users/alice', run2.id, '2026-06-10')).toBe(1); + expect(await repo.listOpenBlockers(standup.id)).toHaveLength(0); + expect(await repo.countBlockersResolvedBetween(standup.id, '2026-06-08', '2026-06-14')).toBe(1); }); - it('migrates a v0.1 database in place', () => { + it('migrates a v0.1 database in place', async () => { const dir = mkdtempSync(join(tmpdir(), 'asyncup-mig-')); const dbPath = join(dir, 'standup.db'); try { @@ -194,11 +194,11 @@ describe('Repo', () => { `); raw.close(); - const repo = new Repo(dbPath); - const standup = repo.listStandupsBySpace('default', 'spaces/x')[0]!; + const repo = await Repo.sqlite(dbPath); + const standup = (await repo.listStandupsBySpace('default', 'spaces/x'))[0]!; expect(standup.name).toBe('Old Standup'); expect(standup.moodEnabled).toBe(true); - const sub = repo.getSubmission(1, 'users/alice')!; + const sub = (await repo.getSubmission(1, 'users/alice'))!; expect(sub.answers).toEqual([ { question: 'What did you do yesterday?', answer: 'Did X' }, { question: 'What will you do today?', answer: 'Will do Y' }, @@ -206,19 +206,19 @@ describe('Repo', () => { ]); expect(sub.mood).toBe('good'); // v2 features work on the migrated DB - repo.addAdmin(standup.id, 'users/ashish', 'Ashish'); - expect(repo.isAdmin(standup.id, 'users/ashish')).toBe(true); - repo.close(); + await repo.addAdmin(standup.id, 'users/ashish', 'Ashish'); + expect(await repo.isAdmin(standup.id, 'users/ashish')).toBe(true); + await repo.close(); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it('caches DM spaces', () => { - const { repo } = makeStack(); - expect(repo.getDmSpace('users/alice')).toBeNull(); - repo.setDmSpace('users/alice', 'spaces/dm1'); - repo.setDmSpace('users/alice', 'spaces/dm2'); - expect(repo.getDmSpace('users/alice')).toBe('spaces/dm2'); + it('caches DM spaces', async () => { + const { repo } = await makeStack(); + expect(await repo.getDmSpace('users/alice')).toBeNull(); + await repo.setDmSpace('users/alice', 'spaces/dm1'); + await repo.setDmSpace('users/alice', 'spaces/dm2'); + expect(await repo.getDmSpace('users/alice')).toBe('spaces/dm2'); }); }); diff --git a/tests/round3.test.ts b/tests/round3.test.ts index ef43565..bc53eba 100644 --- a/tests/round3.test.ts +++ b/tests/round3.test.ts @@ -27,7 +27,7 @@ describe('Calendar OOO sync', () => { it('marks participants with an OOO event as away for the run', async () => { const checker = makeChecker(['bob@org.com']); - const stack = makeStack(); + const stack = await makeStack(); const scheduler = new (await import('../src/core/scheduler.js')).Scheduler( stack.repo, stack.adapter, @@ -37,17 +37,17 @@ describe('Calendar OOO sync', () => { null, checker, ); - const standup = seedStandup(stack.repo); - stack.repo.setUserEmail('users/alice', 'alice@org.com'); - stack.repo.setUserEmail('users/bob', 'bob@org.com'); + const standup = await seedStandup(stack.repo); + await stack.repo.setUserEmail('users/alice', 'alice@org.com'); + await stack.repo.setUserEmail('users/bob', 'bob@org.com'); // carol has no known email — never checked stack.clock.set('2026-06-10T09:30'); await scheduler.tick(); expect(checker.calls.sort()).toEqual(['alice@org.com', 'bob@org.com']); - const run = stack.repo.getRun(standup.id, '2026-06-10')!; - const bob = stack.repo.listRunParticipants(run.id).find((p) => p.userName === 'users/bob')!; + const run = (await stack.repo.getRun(standup.id, '2026-06-10'))!; + const bob = (await stack.repo.listRunParticipants(run.id)).find((p) => p.userName === 'users/bob')!; expect(bob.onVacation).toBe(true); // bob never prompted; persistent participant record untouched expect(stack.adapter.dms.filter((d) => d.kind === 'prompt').map((d) => d.userName).sort()).toEqual([ @@ -55,7 +55,7 @@ describe('Calendar OOO sync', () => { 'users/carol', ]); expect( - stack.repo.listParticipants(standup.id).find((p) => p.userName === 'users/bob')!.onVacation, + (await stack.repo.listParticipants(standup.id)).find((p) => p.userName === 'users/bob')!.onVacation, ).toBe(false); stack.clock.set('2026-06-10T11:30'); @@ -66,7 +66,7 @@ describe('Calendar OOO sync', () => { }); it('treats checker failures as not-OOO', async () => { - const stack = makeStack(); + const stack = await makeStack(); const failing: OooChecker = { async isOoo() { throw new Error('DWD not configured'); @@ -81,8 +81,8 @@ describe('Calendar OOO sync', () => { null, failing, ); - seedStandup(stack.repo); - stack.repo.setUserEmail('users/alice', 'alice@org.com'); + await seedStandup(stack.repo); + await stack.repo.setUserEmail('users/alice', 'alice@org.com'); stack.clock.set('2026-06-10T09:30'); await scheduler.tick(); expect(stack.adapter.dms.filter((d) => d.kind === 'prompt')).toHaveLength(3); @@ -91,61 +91,61 @@ describe('Calendar OOO sync', () => { describe('Anonymous mood', () => { it('hides per-person mood on cards and shows the team average in the wrap-up', async () => { - const { repo, service } = makeStack(); - const standup = seedStandup(repo); - repo.updateStandup(standup.id, { moodAnonymous: true }); - const run = repo.createRun(standup.id, '2026-06-10', 'k'); + const { repo, service } = await makeStack(); + const standup = await seedStandup(repo); + await repo.updateStandup(standup.id, { moodAnonymous: true }); + const run = await repo.createRun(standup.id, '2026-06-10', 'k'); await service.submit(run.id, 'users/alice', 'Alice', { ...ANSWERS, mood: 'great' }); // 5 await service.submit(run.id, 'users/bob', 'Bob', { ...ANSWERS, mood: 'okay' }); // 3 - const summary = service.buildSummary(run.id); + const summary = await service.buildSummary(run.id); expect(summary.teamMood).toBe(4); expect(summaryText(summary)).toContain('💭 Team mood today: 🙂 4/5'); - const sub = repo.getSubmission(run.id, 'users/alice')!; + const sub = (await repo.getSubmission(run.id, 'users/alice'))!; const card = JSON.stringify(submissionMessage(sub, true)); expect(card).toContain('📝 Alice'); expect(card).not.toContain('😄'); }); it('keeps teamMood null when mood is not anonymous', async () => { - const { repo, service } = makeStack(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'k'); + const { repo, service } = await makeStack(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'k'); await service.submit(run.id, 'users/alice', 'Alice', ANSWERS); - expect(service.buildSummary(run.id).teamMood).toBeNull(); + expect((await service.buildSummary(run.id)).teamMood).toBeNull(); }); - it('is configured via `mood anon`', () => { - const { commands, repo } = makeStack(); - commands.handle(ctx('setup')); - expect(commands.handle(ctx('mood anon'))).toContain('anonymous'); - const standup = repo.listStandupsBySpace(TENANT, 'spaces/team')[0]!; + it('is configured via `mood anon`', async () => { + const { commands, repo } = await makeStack(); + await commands.handle(ctx('setup')); + expect(await commands.handle(ctx('mood anon'))).toContain('anonymous'); + const standup = (await repo.listStandupsBySpace(TENANT, 'spaces/team'))[0]!; expect(standup.moodEnabled).toBe(true); expect(standup.moodAnonymous).toBe(true); - expect(commands.handle(ctx('mood on'))).toContain('moods show'); - expect(repo.listStandupsBySpace(TENANT, 'spaces/team')[0]!.moodAnonymous).toBe(false); + expect(await commands.handle(ctx('mood on'))).toContain('moods show'); + expect((await repo.listStandupsBySpace(TENANT, 'spaces/team'))[0]!.moodAnonymous).toBe(false); }); }); describe('Blocker escalation', () => { - it('configures the contact and threshold via commands', () => { - const { commands, repo } = makeStack(); - commands.handle(ctx('setup')); - expect(commands.handle(ctx('escalate'))).toContain('Mention who'); - expect(commands.handle(ctx('escalate @Lead', [LEAD]))).toContain('Lead will be DMed'); - expect(commands.handle(ctx('escalate days 3'))).toContain('after 3 days'); - const standup = repo.listStandupsBySpace(TENANT, 'spaces/team')[0]!; + it('configures the contact and threshold via commands', async () => { + const { commands, repo } = await makeStack(); + await commands.handle(ctx('setup')); + expect(await commands.handle(ctx('escalate'))).toContain('Mention who'); + expect(await commands.handle(ctx('escalate @Lead', [LEAD]))).toContain('Lead will be DMed'); + expect(await commands.handle(ctx('escalate days 3'))).toContain('after 3 days'); + const standup = (await repo.listStandupsBySpace(TENANT, 'spaces/team'))[0]!; expect(standup.escalateUserName).toBe(LEAD.userName); expect(standup.escalateAfterDays).toBe(3); - expect(commands.handle(ctx('escalate off'))).toContain('escalation off'); - expect(repo.listStandupsBySpace(TENANT, 'spaces/team')[0]!.escalateUserName).toBeNull(); + expect(await commands.handle(ctx('escalate off'))).toContain('escalation off'); + expect((await repo.listStandupsBySpace(TENANT, 'spaces/team'))[0]!.escalateUserName).toBeNull(); }); it('DMs the contact once when blockers stay open past the threshold', async () => { - const { repo, adapter, scheduler, service, clock } = makeStack(); - const standup = seedStandup(repo); - repo.updateStandup(standup.id, { + const { repo, adapter, scheduler, service, clock } = await makeStack(); + const standup = await seedStandup(repo); + await repo.updateStandup(standup.id, { escalateUserName: LEAD.userName, escalateDisplayName: LEAD.displayName, escalateAfterDays: 2, @@ -154,7 +154,7 @@ describe('Blocker escalation', () => { // Monday: alice reports a blocker clock.set('2026-06-08T09:30'); await scheduler.tick(); - const mon = repo.getRun(standup.id, '2026-06-08')!; + const mon = (await repo.getRun(standup.id, '2026-06-08'))!; await service.submit(mon.id, 'users/alice', 'Alice', withBlocker('Waiting on API keys')); clock.set('2026-06-08T11:30'); await scheduler.tick(); @@ -181,7 +181,7 @@ describe('Blocker escalation', () => { describe('Email capture', () => { it('learns user emails from interaction events', async () => { - const stack = makeStack(); + const stack = await makeStack(); const router = new EventRouter(stack.commands, stack.service, stack.repo, TENANT); await router.handle({ type: 'MESSAGE', @@ -189,7 +189,7 @@ describe('Email capture', () => { message: { argumentText: 'help' }, user: { name: 'users/alice', displayName: 'Alice', email: 'alice@org.com' }, }); - expect(stack.repo.getUserEmail('users/alice')).toBe('alice@org.com'); - expect(stack.repo.getUserEmail('users/bob')).toBeNull(); + expect(await stack.repo.getUserEmail('users/alice')).toBe('alice@org.com'); + expect(await stack.repo.getUserEmail('users/bob')).toBeNull(); }); }); diff --git a/tests/scheduler.test.ts b/tests/scheduler.test.ts index f214647..8a70b2d 100644 --- a/tests/scheduler.test.ts +++ b/tests/scheduler.test.ts @@ -7,21 +7,21 @@ import { ANSWERS, makeStack, seedStandup } from './helpers.js'; describe('Scheduler', () => { it('does nothing before prompt time', async () => { - const { repo, adapter, scheduler, clock } = makeStack(); - const standup = seedStandup(repo); + const { repo, adapter, scheduler, clock } = await makeStack(); + const standup = await seedStandup(repo); clock.set('2026-06-10T09:00'); await scheduler.tick(); - expect(repo.getRun(standup.id, '2026-06-10')).toBeNull(); + expect(await repo.getRun(standup.id, '2026-06-10')).toBeNull(); expect(adapter.dms).toHaveLength(0); }); it('opens the run, posts the thread parent and prompts everyone at prompt time', async () => { - const { repo, adapter, scheduler, clock } = makeStack(); - const standup = seedStandup(repo); + const { repo, adapter, scheduler, clock } = await makeStack(); + const standup = await seedStandup(repo); clock.set('2026-06-10T09:30'); await scheduler.tick(); - const run = repo.getRun(standup.id, '2026-06-10'); + const run = await repo.getRun(standup.id, '2026-06-10'); expect(run).not.toBeNull(); expect(run!.threadKey).toBe(`standup-${standup.id}-2026-06-10`); expect(adapter.posts.filter((p) => p.kind === 'parent')).toHaveLength(1); @@ -33,8 +33,8 @@ describe('Scheduler', () => { }); it('is idempotent: repeated ticks never re-prompt or re-open', async () => { - const { adapter, scheduler, clock, repo } = makeStack(); - seedStandup(repo); + const { adapter, scheduler, clock, repo } = await makeStack(); + await seedStandup(repo); clock.set('2026-06-10T09:30'); await scheduler.tick(); clock.set('2026-06-10T09:31'); @@ -45,19 +45,19 @@ describe('Scheduler', () => { }); it('never prompts or reminds vacationing or skipped participants', async () => { - const { repo, adapter, scheduler, service, clock } = makeStack(); - const standup = seedStandup(repo); - repo.setParticipantVacation(standup.id, 'users/carol', true); + const { repo, adapter, scheduler, service, clock } = await makeStack(); + const standup = await seedStandup(repo); + await repo.setParticipantVacation(standup.id, 'users/carol', true); clock.set('2026-06-10T09:30'); await scheduler.tick(); - const run = repo.getRun(standup.id, '2026-06-10')!; + const run = (await repo.getRun(standup.id, '2026-06-10'))!; expect(adapter.dms.filter((d) => d.kind === 'prompt').map((d) => d.userName).sort()).toEqual([ 'users/alice', 'users/bob', ]); - service.skipToday(run.id, 'users/bob'); + await service.skipToday(run.id, 'users/bob'); clock.set('2026-06-10T10:30'); await scheduler.tick(); expect(adapter.dms.filter((d) => d.kind === 'reminder').map((d) => d.userName)).toEqual([ @@ -66,11 +66,11 @@ describe('Scheduler', () => { }); it('reminds only non-submitters, once', async () => { - const { repo, adapter, scheduler, service, clock } = makeStack(); - const standup = seedStandup(repo); + const { repo, adapter, scheduler, service, clock } = await makeStack(); + const standup = await seedStandup(repo); clock.set('2026-06-10T09:30'); await scheduler.tick(); - const run = repo.getRun(standup.id, '2026-06-10')!; + const run = (await repo.getRun(standup.id, '2026-06-10'))!; clock.set('2026-06-10T10:00'); await service.submit(run.id, 'users/alice', 'Alice', ANSWERS); @@ -86,11 +86,11 @@ describe('Scheduler', () => { }); it('closes at the deadline and posts a summary with count, missing and away names', async () => { - const { repo, adapter, scheduler, service, clock } = makeStack(); - const standup = seedStandup(repo); + const { repo, adapter, scheduler, service, clock } = await makeStack(); + const standup = await seedStandup(repo); clock.set('2026-06-10T09:30'); await scheduler.tick(); - const run = repo.getRun(standup.id, '2026-06-10')!; + const run = (await repo.getRun(standup.id, '2026-06-10'))!; clock.set('2026-06-10T10:00'); await service.submit(run.id, 'users/alice', 'Alice', ANSWERS); @@ -99,7 +99,7 @@ describe('Scheduler', () => { clock.set('2026-06-10T11:30'); await scheduler.tick(); - expect(repo.getRunById(run.id)!.status).toBe('closed'); + expect((await repo.getRunById(run.id))!.status).toBe('closed'); const summaries = adapter.posts.filter((p) => p.kind === 'summary'); expect(summaries).toHaveLength(1); const summary = summaries[0]!.payload as RunSummary; @@ -116,30 +116,30 @@ describe('Scheduler', () => { }); it('skips days the standup is not configured for', async () => { - const { repo, adapter, scheduler, clock } = makeStack(); - const standup = seedStandup(repo); + const { repo, adapter, scheduler, clock } = await makeStack(); + const standup = await seedStandup(repo); clock.set('2026-06-13T09:30'); // Saturday await scheduler.tick(); - expect(repo.getRun(standup.id, '2026-06-13')).toBeNull(); + expect(await repo.getRun(standup.id, '2026-06-13')).toBeNull(); expect(adapter.dms).toHaveLength(0); }); it('does not open a run when everyone is on vacation', async () => { - const { repo, adapter, scheduler, clock } = makeStack(); - const standup = seedStandup(repo); - for (const p of repo.listParticipants(standup.id)) { - repo.setParticipantVacation(standup.id, p.userName, true); + const { repo, adapter, scheduler, clock } = await makeStack(); + const standup = await seedStandup(repo); + for (const p of await repo.listParticipants(standup.id)) { + await repo.setParticipantVacation(standup.id, p.userName, true); } clock.set('2026-06-10T09:30'); await scheduler.tick(); - expect(repo.getRun(standup.id, '2026-06-10')).toBeNull(); + expect(await repo.getRun(standup.id, '2026-06-10')).toBeNull(); expect(adapter.posts).toHaveLength(0); }); it('prompts each participant at prompt time in their own timezone', async () => { - const { repo, adapter, scheduler, clock } = makeStack(); - const standup = seedStandup(repo, { deadlineTime: '18:00' }); - repo.setParticipantTimezone(standup.id, 'users/bob', 'Europe/London'); + const { repo, adapter, scheduler, clock } = await makeStack(); + const standup = await seedStandup(repo, { deadlineTime: '18:00' }); + await repo.setParticipantTimezone(standup.id, 'users/bob', 'Europe/London'); clock.set('2026-06-10T09:30'); await scheduler.tick(); @@ -157,27 +157,27 @@ describe('Scheduler', () => { }); it('closes runs left open from previous days (e.g. after downtime)', async () => { - const { repo, adapter, scheduler, clock } = makeStack(); - const standup = seedStandup(repo); + const { repo, adapter, scheduler, clock } = await makeStack(); + const standup = await seedStandup(repo); clock.set('2026-06-10T09:30'); await scheduler.tick(); - expect(repo.getRun(standup.id, '2026-06-10')!.status).toBe('open'); + expect((await repo.getRun(standup.id, '2026-06-10'))!.status).toBe('open'); clock.set('2026-06-11T08:00'); await scheduler.tick(); - expect(repo.getRun(standup.id, '2026-06-10')!.status).toBe('closed'); + expect((await repo.getRun(standup.id, '2026-06-10'))!.status).toBe('closed'); expect(adapter.posts.filter((p) => p.kind === 'summary')).toHaveLength(1); }); it('posts the weekly digest after the last configured day of the week', async () => { - const { repo, adapter, scheduler, service, clock } = makeStack(); - const standup = seedStandup(repo); - repo.updateStandup(standup.id, { digestEnabled: true }); + const { repo, adapter, scheduler, service, clock } = await makeStack(); + const standup = await seedStandup(repo); + await repo.updateStandup(standup.id, { digestEnabled: true }); // Wednesday run: closes without a digest clock.set('2026-06-10T09:30'); await scheduler.tick(); - const wed = repo.getRun(standup.id, '2026-06-10')!; + const wed = (await repo.getRun(standup.id, '2026-06-10'))!; await service.submit(wed.id, 'users/alice', 'Alice', ANSWERS); clock.set('2026-06-10T11:30'); await scheduler.tick(); @@ -186,7 +186,7 @@ describe('Scheduler', () => { // Friday run: digest follows the close clock.set('2026-06-12T09:30'); await scheduler.tick(); - const fri = repo.getRun(standup.id, '2026-06-12')!; + const fri = (await repo.getRun(standup.id, '2026-06-12'))!; await service.submit(fri.id, 'users/alice', 'Alice', ANSWERS); clock.set('2026-06-12T11:30'); await scheduler.tick(); @@ -200,15 +200,15 @@ describe('Scheduler', () => { it('posts an AI summary after close when enabled and configured', async () => { const fakeLlm = async (_system: string, prompt: string) => `TLDR for: ${prompt.slice(0, 20)}…`; - const { repo, adapter, scheduler, service, clock } = makeStack({ + const { repo, adapter, scheduler, service, clock } = await makeStack({ summarizer: new AiSummarizer(fakeLlm), }); - const standup = seedStandup(repo); - repo.updateStandup(standup.id, { aiEnabled: true }); + const standup = await seedStandup(repo); + await repo.updateStandup(standup.id, { aiEnabled: true }); clock.set('2026-06-10T09:30'); await scheduler.tick(); - const run = repo.getRun(standup.id, '2026-06-10')!; + const run = (await repo.getRun(standup.id, '2026-06-10'))!; await service.submit(run.id, 'users/alice', 'Alice', ANSWERS); clock.set('2026-06-10T11:30'); @@ -222,9 +222,9 @@ describe('Scheduler', () => { it('never posts an AI summary when no one submitted', async () => { const fakeLlm = async () => 'should not be called'; - const { repo, adapter, scheduler, clock } = makeStack({ summarizer: new AiSummarizer(fakeLlm) }); - const standup = seedStandup(repo); - repo.updateStandup(standup.id, { aiEnabled: true }); + const { repo, adapter, scheduler, clock } = await makeStack({ summarizer: new AiSummarizer(fakeLlm) }); + const standup = await seedStandup(repo); + await repo.updateStandup(standup.id, { aiEnabled: true }); clock.set('2026-06-10T09:30'); await scheduler.tick(); diff --git a/tests/server.test.ts b/tests/server.test.ts index 42bace8..080bb83 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -6,8 +6,8 @@ import { ANSWERS, makeStack, seedStandup, TENANT } from './helpers.js'; let close: (() => void) | null = null; -function startServer(opts: { tickToken?: string; exportToken?: string; dashboardToken?: string } = {}) { - const stack = makeStack(); +async function startServer(opts: { tickToken?: string; exportToken?: string; dashboardToken?: string } = {}) { + const stack = await makeStack(); const router = new EventRouter(stack.commands, stack.service, stack.repo, TENANT); const app = createServer({ router, @@ -32,15 +32,15 @@ afterEach(() => { describe('server', () => { it('responds to health checks', async () => { - const { url } = startServer(); + const { url } = await startServer(); const res = await fetch(`${url}/healthz`); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); }); it('drives the scheduler via POST /tick', async () => { - const { url, repo, adapter, clock } = startServer(); - seedStandup(repo); + const { url, repo, adapter, clock } = await startServer(); + await seedStandup(repo); clock.set('2026-06-10T09:30'); const res = await fetch(`${url}/tick`, { method: 'POST' }); @@ -49,7 +49,7 @@ describe('server', () => { }); it('protects /tick when TICK_TOKEN is configured', async () => { - const { url } = startServer({ tickToken: 's3cret' }); + const { url } = await startServer({ tickToken: 's3cret' }); expect((await fetch(`${url}/tick`, { method: 'POST' })).status).toBe(401); expect( ( @@ -70,7 +70,7 @@ describe('server', () => { }); it('routes chat events', async () => { - const { url } = startServer(); + const { url } = await startServer(); const res = await fetch(`${url}/chat/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -86,13 +86,13 @@ describe('server', () => { }); it('disables /export without EXPORT_TOKEN and guards it with one', async () => { - const disabled = startServer(); + const disabled = await startServer(); expect((await fetch(`${disabled.url}/export?standupId=1`)).status).toBe(404); close?.(); - const { url, repo, service, clock } = startServer({ exportToken: 'csv-secret' }); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-09', 'k'); + const { url, repo, service, clock } = await startServer({ exportToken: 'csv-secret' }); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-09', 'k'); await service.submit(run.id, 'users/alice', 'Alice', ANSWERS); clock.set('2026-06-10T12:00'); diff --git a/tests/service.test.ts b/tests/service.test.ts index 1014260..11c9df6 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -4,9 +4,9 @@ import { ANSWERS, makeStack, seedStandup, withBlocker } from './helpers.js'; describe('StandupService', () => { it('records a submission, posts it, and stores the message name', async () => { - const { repo, adapter, service } = makeStack(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + const { repo, adapter, service } = await makeStack(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); const result = await service.submit(run.id, 'users/alice', 'Alice', ANSWERS); expect(result).toEqual({ ok: true, late: false, edited: false }); @@ -14,19 +14,19 @@ describe('StandupService', () => { const posts = adapter.posts.filter((p) => p.kind === 'submission'); expect(posts).toHaveLength(1); expect((posts[0]!.payload as Submission).mood).toBe('good'); - expect(repo.getSubmission(run.id, 'users/alice')!.messageName).toBe(posts[0]!.messageName); + expect((await repo.getSubmission(run.id, 'users/alice'))!.messageName).toBe(posts[0]!.messageName); }); it('edits an existing submission while the run is open and updates the card', async () => { - const { repo, adapter, service } = makeStack(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + const { repo, adapter, service } = await makeStack(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); await service.submit(run.id, 'users/alice', 'Alice', ANSWERS); const result = await service.submit(run.id, 'users/alice', 'Alice', withBlocker('Stuck on infra')); expect(result).toEqual({ ok: true, late: false, edited: true }); - const sub = repo.getSubmission(run.id, 'users/alice')!; + const sub = (await repo.getSubmission(run.id, 'users/alice'))!; expect(sub.editedAt).not.toBeNull(); expect(sub.mood).toBe('meh'); expect(adapter.posts.filter((p) => p.kind === 'update')).toHaveLength(1); @@ -34,9 +34,9 @@ describe('StandupService', () => { }); it('rejects edits after close, unknown runs and non-participants', async () => { - const { repo, service } = makeStack(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + const { repo, service } = await makeStack(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); expect(await service.submit(999, 'users/alice', 'Alice', ANSWERS)).toEqual({ ok: false, @@ -48,7 +48,7 @@ describe('StandupService', () => { }); await service.submit(run.id, 'users/alice', 'Alice', ANSWERS); - repo.closeRun(run.id); + await repo.closeRun(run.id); expect(await service.submit(run.id, 'users/alice', 'Alice', ANSWERS)).toEqual({ ok: false, reason: 'already_submitted', @@ -56,10 +56,10 @@ describe('StandupService', () => { }); it('flags submissions to closed runs as late', async () => { - const { repo, service } = makeStack(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); - repo.closeRun(run.id); + const { repo, service } = await makeStack(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); + await repo.closeRun(run.id); expect(await service.submit(run.id, 'users/alice', 'Alice', ANSWERS)).toEqual({ ok: true, @@ -69,58 +69,60 @@ describe('StandupService', () => { }); it('opens blockers from blocker answers and auto-resolves on the next clean submission', async () => { - const { repo, service } = makeStack(); - const standup = seedStandup(repo); + const { repo, service } = await makeStack(); + const standup = await seedStandup(repo); - const run1 = repo.createRun(standup.id, '2026-06-09', 'k1'); + const run1 = await repo.createRun(standup.id, '2026-06-09', 'k1'); await service.submit(run1.id, 'users/alice', 'Alice', withBlocker('Waiting on API keys')); - expect(repo.listOpenBlockers(standup.id).map((b) => b.text)).toEqual(['Waiting on API keys']); + expect((await repo.listOpenBlockers(standup.id)).map((b) => b.text)).toEqual(['Waiting on API keys']); - const run2 = repo.createRun(standup.id, '2026-06-10', 'k2'); + const run2 = await repo.createRun(standup.id, '2026-06-10', 'k2'); await service.submit(run2.id, 'users/alice', 'Alice', ANSWERS); - expect(repo.listOpenBlockers(standup.id)).toHaveLength(0); + expect(await repo.listOpenBlockers(standup.id)).toHaveLength(0); }); it('re-derives blockers on edit without resolving older ones', async () => { - const { repo, service } = makeStack(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + const { repo, service } = await makeStack(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); await service.submit(run.id, 'users/alice', 'Alice', withBlocker('Blocker A')); await service.submit(run.id, 'users/alice', 'Alice', withBlocker('Blocker B')); - expect(repo.listOpenBlockers(standup.id).map((b) => b.text)).toEqual(['Blocker B']); + expect((await repo.listOpenBlockers(standup.id)).map((b) => b.text)).toEqual(['Blocker B']); // editing to blocker-free clears today's blocker but doesn't resolve history await service.submit(run.id, 'users/alice', 'Alice', ANSWERS); - expect(repo.listOpenBlockers(standup.id)).toHaveLength(0); + expect(await repo.listOpenBlockers(standup.id)).toHaveLength(0); }); - it('skips today only before submitting', () => { - const { repo, service } = makeStack(); - const standup = seedStandup(repo); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + it('skips today only before submitting', async () => { + const { repo, service } = await makeStack(); + const standup = await seedStandup(repo); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); - expect(service.skipToday(run.id, 'users/alice')).toBe('skipped'); - expect(repo.listRunParticipants(run.id).find((p) => p.userName === 'users/alice')?.skippedAt).not.toBeNull(); - expect(service.skipToday(999, 'users/alice')).toBe('not_found'); + expect(await service.skipToday(run.id, 'users/alice')).toBe('skipped'); + expect( + (await repo.listRunParticipants(run.id)).find((p) => p.userName === 'users/alice')?.skippedAt, + ).not.toBeNull(); + expect(await service.skipToday(999, 'users/alice')).toBe('not_found'); }); it('prefills yesterday from the previous today, and full answers when editing', async () => { - const { repo, service } = makeStack(); - const standup = seedStandup(repo); - const run1 = repo.createRun(standup.id, '2026-06-09', 'k1'); + const { repo, service } = await makeStack(); + const standup = await seedStandup(repo); + const run1 = await repo.createRun(standup.id, '2026-06-09', 'k1'); await service.submit(run1.id, 'users/alice', 'Alice', ANSWERS); - const run2 = repo.createRun(standup.id, '2026-06-10', 'k2'); - expect(service.getPrefill(standup, run2, 'users/alice')).toEqual([ + const run2 = await repo.createRun(standup.id, '2026-06-10', 'k2'); + expect(await service.getPrefill(standup, run2, 'users/alice')).toEqual([ 'Start billing webhooks', // previous "today" '', '', ]); - expect(service.getPrefill(standup, run2, 'users/bob')).toEqual(['', '', '']); + expect(await service.getPrefill(standup, run2, 'users/bob')).toEqual(['', '', '']); await service.submit(run2.id, 'users/alice', 'Alice', withBlocker('Stuck')); - expect(service.getPrefill(standup, run2, 'users/alice')).toEqual([ + expect(await service.getPrefill(standup, run2, 'users/alice')).toEqual([ 'Worked on infra', 'More infra', 'Stuck', @@ -128,17 +130,17 @@ describe('StandupService', () => { }); it('builds summaries with away/skip handling and open blockers', async () => { - const { repo, service } = makeStack(); - const standup = seedStandup(repo); - repo.upsertParticipant({ standupId: standup.id, userName: 'users/dave', displayName: 'Dave' }); - repo.setParticipantVacation(standup.id, 'users/dave', true); - const run = repo.createRun(standup.id, '2026-06-10', 'key'); + const { repo, service } = await makeStack(); + const standup = await seedStandup(repo); + await repo.upsertParticipant({ standupId: standup.id, userName: 'users/dave', displayName: 'Dave' }); + await repo.setParticipantVacation(standup.id, 'users/dave', true); + const run = await repo.createRun(standup.id, '2026-06-10', 'key'); await service.submit(run.id, 'users/alice', 'Alice', withBlocker('Stuck on infra')); await service.submit(run.id, 'users/carol', 'Carol', ANSWERS); // optional - service.skipToday(run.id, 'users/bob'); + await service.skipToday(run.id, 'users/bob'); - const summary = service.buildSummary(run.id); + const summary = await service.buildSummary(run.id); expect(summary.mandatoryTotal).toBe(1); // bob skipped + dave on vacation excluded expect(summary.mandatorySubmitted).toBe(1); expect(summary.missingMandatory).toEqual([]);